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>
+2 -1
View File
@@ -23,7 +23,8 @@ public class AppDbContext : DbContext
public DbSet<LiveEncounter> LiveEncounters => Set<LiveEncounter>();
public DbSet<LiveObservation> LiveObservations => Set<LiveObservation>();
public DbSet<PromotionAttempt> PromotionAttempts => Set<PromotionAttempt>();
public DbSet<CoverSheet> CoverSheets => Set<CoverSheet>();
protected override void OnModelCreating(ModelBuilder modelBuilder)
{
modelBuilder.ApplyConfigurationsFromAssembly(typeof(AppDbContext).Assembly);
@@ -0,0 +1,40 @@
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Metadata.Builders;
public class CoverSheetConfiguration : IEntityTypeConfiguration<CoverSheet>
{
public void Configure(EntityTypeBuilder<CoverSheet> builder)
{
builder.ToTable("cover_sheets");
builder.HasKey(c => c.Id);
builder.Property(c => c.Id).HasColumnName("id").HasDefaultValueSql("gen_random_uuid()");
builder.Property(c => c.Code).HasColumnName("code").HasMaxLength(20).IsRequired();
builder.Property(c => c.PatientId).HasColumnName("patient_id");
builder.Property(c => c.BatchType).HasColumnName("batch_type")
.HasConversion(v => v.ToDbString(), v => BatchTypeExtensions.FromDbString(v))
.HasMaxLength(30).IsRequired();
builder.Property(c => c.Track).HasColumnName("track")
.HasConversion(v => v.ToDbString(), v => BatchTrackExtensions.FromDbString(v))
.HasMaxLength(20).IsRequired();
builder.Property(c => c.AssignToUserId).HasColumnName("assign_to_user_id");
builder.Property(c => c.GeneratedByUserId).HasColumnName("generated_by_user_id").IsRequired();
builder.Property(c => c.IsUsed).HasColumnName("is_used").HasDefaultValue(false);
builder.Property(c => c.BatchId).HasColumnName("batch_id");
builder.Property(c => c.CreatedAt).HasColumnName("created_at").HasDefaultValueSql("NOW()");
builder.Property(c => c.UsedAt).HasColumnName("used_at");
builder.HasIndex(c => c.Code).IsUnique().HasDatabaseName("ix_cover_sheets_code");
builder.HasIndex(c => new { c.IsUsed, c.CreatedAt })
.HasFilter("is_used = false")
.HasDatabaseName("ix_cover_sheets_unused");
builder.HasOne(c => c.Patient)
.WithMany().HasForeignKey(c => c.PatientId).OnDelete(DeleteBehavior.SetNull);
builder.HasOne(c => c.AssignToUser)
.WithMany().HasForeignKey(c => c.AssignToUserId).OnDelete(DeleteBehavior.SetNull);
builder.HasOne(c => c.GeneratedByUser)
.WithMany().HasForeignKey(c => c.GeneratedByUserId).OnDelete(DeleteBehavior.Restrict);
builder.HasOne(c => c.Batch)
.WithMany().HasForeignKey(c => c.BatchId).OnDelete(DeleteBehavior.SetNull);
}
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,100 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace VigilCareRecordsAPI.Data.Migrations
{
/// <inheritdoc />
public partial class AddCoverSheets : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.CreateTable(
name: "cover_sheets",
columns: table => new
{
id = table.Column<Guid>(type: "uuid", nullable: false, defaultValueSql: "gen_random_uuid()"),
code = table.Column<string>(type: "character varying(20)", maxLength: 20, nullable: false),
patient_id = table.Column<Guid>(type: "uuid", nullable: true),
batch_type = table.Column<string>(type: "character varying(30)", maxLength: 30, nullable: false),
track = table.Column<string>(type: "character varying(20)", maxLength: 20, nullable: false),
assign_to_user_id = table.Column<Guid>(type: "uuid", nullable: true),
generated_by_user_id = table.Column<Guid>(type: "uuid", nullable: false),
is_used = table.Column<bool>(type: "boolean", nullable: false, defaultValue: false),
batch_id = table.Column<Guid>(type: "uuid", nullable: true),
created_at = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: false, defaultValueSql: "NOW()"),
used_at = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: true)
},
constraints: table =>
{
table.PrimaryKey("PK_cover_sheets", x => x.id);
table.ForeignKey(
name: "FK_cover_sheets_digitization_batches_batch_id",
column: x => x.batch_id,
principalTable: "digitization_batches",
principalColumn: "id",
onDelete: ReferentialAction.SetNull);
table.ForeignKey(
name: "FK_cover_sheets_patients_patient_id",
column: x => x.patient_id,
principalSchema: "clinical",
principalTable: "patients",
principalColumn: "id",
onDelete: ReferentialAction.SetNull);
table.ForeignKey(
name: "FK_cover_sheets_users_assign_to_user_id",
column: x => x.assign_to_user_id,
principalTable: "users",
principalColumn: "id",
onDelete: ReferentialAction.SetNull);
table.ForeignKey(
name: "FK_cover_sheets_users_generated_by_user_id",
column: x => x.generated_by_user_id,
principalTable: "users",
principalColumn: "id",
onDelete: ReferentialAction.Restrict);
});
migrationBuilder.CreateIndex(
name: "IX_cover_sheets_assign_to_user_id",
table: "cover_sheets",
column: "assign_to_user_id");
migrationBuilder.CreateIndex(
name: "IX_cover_sheets_batch_id",
table: "cover_sheets",
column: "batch_id");
migrationBuilder.CreateIndex(
name: "ix_cover_sheets_code",
table: "cover_sheets",
column: "code",
unique: true);
migrationBuilder.CreateIndex(
name: "IX_cover_sheets_generated_by_user_id",
table: "cover_sheets",
column: "generated_by_user_id");
migrationBuilder.CreateIndex(
name: "IX_cover_sheets_patient_id",
table: "cover_sheets",
column: "patient_id");
migrationBuilder.CreateIndex(
name: "ix_cover_sheets_unused",
table: "cover_sheets",
columns: new[] { "is_used", "created_at" },
filter: "is_used = false");
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropTable(
name: "cover_sheets");
}
}
}
@@ -230,6 +230,85 @@ namespace VigilCareRecordsAPI.Data.Migrations
});
});
modelBuilder.Entity("CoverSheet", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid")
.HasColumnName("id")
.HasDefaultValueSql("gen_random_uuid()");
b.Property<Guid?>("AssignToUserId")
.HasColumnType("uuid")
.HasColumnName("assign_to_user_id");
b.Property<Guid?>("BatchId")
.HasColumnType("uuid")
.HasColumnName("batch_id");
b.Property<string>("BatchType")
.IsRequired()
.HasMaxLength(30)
.HasColumnType("character varying(30)")
.HasColumnName("batch_type");
b.Property<string>("Code")
.IsRequired()
.HasMaxLength(20)
.HasColumnType("character varying(20)")
.HasColumnName("code");
b.Property<DateTimeOffset>("CreatedAt")
.ValueGeneratedOnAdd()
.HasColumnType("timestamp with time zone")
.HasColumnName("created_at")
.HasDefaultValueSql("NOW()");
b.Property<Guid>("GeneratedByUserId")
.HasColumnType("uuid")
.HasColumnName("generated_by_user_id");
b.Property<bool>("IsUsed")
.ValueGeneratedOnAdd()
.HasColumnType("boolean")
.HasDefaultValue(false)
.HasColumnName("is_used");
b.Property<Guid?>("PatientId")
.HasColumnType("uuid")
.HasColumnName("patient_id");
b.Property<string>("Track")
.IsRequired()
.HasMaxLength(20)
.HasColumnType("character varying(20)")
.HasColumnName("track");
b.Property<DateTimeOffset?>("UsedAt")
.HasColumnType("timestamp with time zone")
.HasColumnName("used_at");
b.HasKey("Id");
b.HasIndex("AssignToUserId");
b.HasIndex("BatchId");
b.HasIndex("Code")
.IsUnique()
.HasDatabaseName("ix_cover_sheets_code");
b.HasIndex("GeneratedByUserId");
b.HasIndex("PatientId");
b.HasIndex("IsUsed", "CreatedAt")
.HasDatabaseName("ix_cover_sheets_unused")
.HasFilter("is_used = false");
b.ToTable("cover_sheets", (string)null);
});
modelBuilder.Entity("DigitizationBatch", b =>
{
b.Property<Guid>("Id")
@@ -1269,6 +1348,38 @@ namespace VigilCareRecordsAPI.Data.Migrations
b.Navigation("User");
});
modelBuilder.Entity("CoverSheet", b =>
{
b.HasOne("User", "AssignToUser")
.WithMany()
.HasForeignKey("AssignToUserId")
.OnDelete(DeleteBehavior.SetNull);
b.HasOne("DigitizationBatch", "Batch")
.WithMany()
.HasForeignKey("BatchId")
.OnDelete(DeleteBehavior.SetNull);
b.HasOne("User", "GeneratedByUser")
.WithMany()
.HasForeignKey("GeneratedByUserId")
.OnDelete(DeleteBehavior.Restrict)
.IsRequired();
b.HasOne("Patient", "Patient")
.WithMany()
.HasForeignKey("PatientId")
.OnDelete(DeleteBehavior.SetNull);
b.Navigation("AssignToUser");
b.Navigation("Batch");
b.Navigation("GeneratedByUser");
b.Navigation("Patient");
});
modelBuilder.Entity("DigitizationBatch", b =>
{
b.HasOne("User", "ApprovedByUser")
@@ -0,0 +1,19 @@
public class CoverSheet
{
public Guid Id { get; set; }
public string Code { get; set; } = null!;
public Guid? PatientId { get; set; }
public BatchType BatchType { get; set; }
public BatchTrack Track { get; set; }
public Guid? AssignToUserId { get; set; }
public Guid GeneratedByUserId { get; set; }
public bool IsUsed { get; set; }
public Guid? BatchId { get; set; }
public DateTimeOffset CreatedAt { get; set; }
public DateTimeOffset? UsedAt { get; set; }
public Patient? Patient { get; set; }
public User? AssignToUser { get; set; }
public User GeneratedByUser { get; set; } = null!;
public DigitizationBatch? Batch { get; set; }
}
@@ -5,8 +5,10 @@ public class CreateBatchForm
[Required]
public IFormFile File { get; set; } = null!;
[Required]
public string BatchType { get; set; } = null!;
/// <summary>
/// Required unless <see cref="CoverSheetCode"/> is provided; cover sheet values override when both are sent.
/// </summary>
public string? BatchType { get; set; }
public string? Track { get; set; }
@@ -15,5 +17,8 @@ public class CreateBatchForm
public Guid? SupersedesBatchId { get; set; }
public CreateBatchRequest ToMetadata() =>
new(BatchType, Track, PatientId, SupersedesBatchId);
new(BatchType ?? throw new InvalidOperationException("BatchType is required."),
Track, PatientId, SupersedesBatchId);
public string? CoverSheetCode { get; set; }
}
@@ -0,0 +1 @@
public record BatchPdfRequest(List<Guid> CoverSheetIds);
@@ -0,0 +1,15 @@
public record CoverSheetResponse(
Guid Id,
string Code,
string BatchType,
string Track,
Guid? PatientId,
string? PatientName,
string? PatientMrn,
Guid? AssignToUserId,
string? AssignToUserName,
bool IsUsed,
Guid? BatchId,
DateTimeOffset CreatedAt,
DateTimeOffset? UsedAt
);
@@ -0,0 +1,7 @@
public record GenerateCoverSheetsRequest(
int Count,
string BatchType,
string Track,
Guid? PatientId,
Guid? AssignToUserId
);
+2 -1
View File
@@ -121,7 +121,8 @@ try
builder.Services.AddScoped<IAttestationService, AttestationService>();
builder.Services.AddScoped<ILiveCaptureService, LiveCaptureService>();
builder.Services.AddScoped<IBatchEventService, BatchEventService>();
builder.Services.AddScoped<ICoverSheetService, CoverSheetService>();
builder.Services.AddHostedService<MetricsCollectorService>();
builder.Services.AddHostedService<PromotionRetryService>();
@@ -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);
}
@@ -23,6 +23,7 @@
<PackageReference Include="Npgsql.EntityFrameworkCore.PostgreSQL" Version="8.0.4" />
<PackageReference Include="prometheus-net" Version="8.2.1" />
<PackageReference Include="prometheus-net.AspNetCore" Version="8.2.1" />
<PackageReference Include="QRCoder" Version="1.6.0" />
<PackageReference Include="Serilog.AspNetCore" Version="8.0.2" />
<PackageReference Include="Serilog.Sinks.Console" Version="5.0.1" />
<PackageReference Include="Serilog.Sinks.Seq" Version="9.1.0" />