feature: Corrections and Supersession
This commit is contained in:
@@ -14,28 +14,36 @@ public class DigitizationBatchesController : ControllerBase
|
||||
{
|
||||
private readonly IBatchService _batches;
|
||||
private readonly IDocumentStorageService _storage;
|
||||
private readonly IPromotionService _promotion;
|
||||
|
||||
private static readonly HashSet<string> _allowedMimeTypes = new()
|
||||
{
|
||||
"application/pdf", "image/jpeg", "image/png"
|
||||
};
|
||||
|
||||
public DigitizationBatchesController(IBatchService batches, IDocumentStorageService storage)
|
||||
public DigitizationBatchesController(
|
||||
IBatchService batches,
|
||||
IDocumentStorageService storage,
|
||||
IPromotionService promotion)
|
||||
{
|
||||
_batches = batches;
|
||||
_storage = storage;
|
||||
_promotion = promotion;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Uploads a scanned document and creates a new digitization batch.
|
||||
/// When supersedesBatchId is provided, the batch is treated as a correction
|
||||
/// that will supersede the erroneous promoted batch upon its own promotion.
|
||||
/// </summary>
|
||||
[HttpPost]
|
||||
[Consumes("multipart/form-data")]
|
||||
[ProducesResponseType(typeof(ApiResponse<BatchDetailResponse>), StatusCodes.Status201Created)]
|
||||
[ProducesResponseType(typeof(ApiResponse<object>), StatusCodes.Status400BadRequest)]
|
||||
[ProducesResponseType(typeof(ApiResponse<object>), StatusCodes.Status401Unauthorized)]
|
||||
[ProducesResponseType(typeof(ApiResponse<object>), StatusCodes.Status404NotFound)]
|
||||
[ProducesResponseType(typeof(ApiResponse<object>), StatusCodes.Status409Conflict)]
|
||||
[ProducesResponseType(typeof(ApiResponse<object>), StatusCodes.Status422UnprocessableEntity)]
|
||||
[RequestSizeLimit(25 * 1024 * 1024)]
|
||||
[Consumes("multipart/form-data")]
|
||||
public async Task<IActionResult> Create([FromForm] CreateBatchForm form)
|
||||
{
|
||||
if (form.File is null || form.File.Length == 0)
|
||||
@@ -45,17 +53,19 @@ public class DigitizationBatchesController : ControllerBase
|
||||
return BadRequest(ApiResponse<object>.Fail(400,
|
||||
"Accepted formats: PDF, JPEG, PNG.", "INVALID_MIME_TYPE"));
|
||||
|
||||
var req = form.ToMetadata();
|
||||
var parsedBatchType = BatchTypeExtensions.FromDbString(req.BatchType.ToUpperInvariant());
|
||||
var parsedTrack = string.IsNullOrEmpty(req.Track)
|
||||
var parsedBatchType = BatchTypeExtensions.FromDbString(form.BatchType.ToUpperInvariant());
|
||||
var parsedTrack = string.IsNullOrEmpty(form.Track)
|
||||
? BatchTrack.Backfill
|
||||
: BatchTrackExtensions.FromDbString(req.Track.ToUpperInvariant());
|
||||
: BatchTrackExtensions.FromDbString(form.Track.ToUpperInvariant());
|
||||
var actorUserId = Guid.Parse(User.FindFirstValue(ClaimTypes.NameIdentifier)!);
|
||||
|
||||
using var stream = form.File.OpenReadStream();
|
||||
var batch = await _batches.CreateAsync(
|
||||
stream, form.File.ContentType, parsedBatchType, parsedTrack, req.PatientId, actorUserId);
|
||||
return StatusCode(201, ApiResponse<BatchDetailResponse>.Created(BatchDetailResponse.FromEntity(batch)));
|
||||
var result = await _batches.CreateAsync(
|
||||
stream, form.File.ContentType, parsedBatchType, parsedTrack,
|
||||
form.PatientId, form.SupersedesBatchId, actorUserId);
|
||||
|
||||
return StatusCode(201, ApiResponse<BatchDetailResponse>.Created(
|
||||
BatchDetailResponse.FromEntity(result.Batch, supersession: result.Supersession)));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -115,4 +125,21 @@ public class DigitizationBatchesController : ControllerBase
|
||||
var batch = await _batches.AssignAsync(id, req.EntryClerkUserId, actorUserId);
|
||||
return Ok(ApiResponse<BatchDetailResponse>.Ok(BatchDetailResponse.FromEntity(batch)));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Promotes an approved batch to live clinical data. For correction batches,
|
||||
/// marks the original batch's live observations as superseded (append-only).
|
||||
/// </summary>
|
||||
[HttpPost("{id:guid}/promote")]
|
||||
[Authorize(Roles = "CLINICAL_APPROVER,ADMINISTRATOR")]
|
||||
[ProducesResponseType(typeof(ApiResponse<PromotionResult>), StatusCodes.Status200OK)]
|
||||
[ProducesResponseType(typeof(ApiResponse<object>), StatusCodes.Status404NotFound)]
|
||||
[ProducesResponseType(typeof(ApiResponse<object>), StatusCodes.Status409Conflict)]
|
||||
[ProducesResponseType(typeof(ApiResponse<object>), StatusCodes.Status422UnprocessableEntity)]
|
||||
public async Task<IActionResult> Promote(Guid id)
|
||||
{
|
||||
var actorUserId = Guid.Parse(User.FindFirstValue(ClaimTypes.NameIdentifier)!);
|
||||
var result = await _promotion.PromoteAsync(id, actorUserId);
|
||||
return Ok(ApiResponse<PromotionResult>.Ok(result));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
|
||||
|
||||
/// <summary>
|
||||
/// Patient-scoped endpoints for digitization history and audit trail.
|
||||
/// </summary>
|
||||
[ApiController]
|
||||
[Route("api/v1/patients")]
|
||||
[Produces("application/json")]
|
||||
[Authorize]
|
||||
public class PatientsController : ControllerBase
|
||||
{
|
||||
private readonly IDigitizationHistoryService _history;
|
||||
|
||||
public PatientsController(IDigitizationHistoryService history) =>
|
||||
_history = history;
|
||||
|
||||
/// <summary>
|
||||
/// Returns the complete digitization history for a patient, including all
|
||||
/// batches, their promotion status, correction chains, and per-batch audit trails.
|
||||
/// Superseded observations are included with their supersession metadata.
|
||||
/// </summary>
|
||||
[HttpGet("{patientId:guid}/digitization-history")]
|
||||
[ProducesResponseType(typeof(ApiResponse<PatientDigitizationHistoryResponse>), StatusCodes.Status200OK)]
|
||||
[ProducesResponseType(typeof(ApiResponse<object>), StatusCodes.Status404NotFound)]
|
||||
public async Task<IActionResult> GetDigitizationHistory(Guid patientId)
|
||||
{
|
||||
var history = await _history.GetPatientHistoryAsync(patientId);
|
||||
return Ok(ApiResponse<PatientDigitizationHistoryResponse>.Ok(history));
|
||||
}
|
||||
}
|
||||
@@ -18,6 +18,8 @@ public class AppDbContext : DbContext
|
||||
public DbSet<Encounter> Encounters => Set<Encounter>();
|
||||
public DbSet<Observation> Observations => Set<Observation>();
|
||||
public DbSet<OutboxEvent> OutboxEvents => Set<OutboxEvent>();
|
||||
public DbSet<LiveEncounter> LiveEncounters => Set<LiveEncounter>();
|
||||
public DbSet<LiveObservation> LiveObservations => Set<LiveObservation>();
|
||||
|
||||
protected override void OnModelCreating(ModelBuilder modelBuilder)
|
||||
{
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.EntityFrameworkCore.Metadata.Builders;
|
||||
|
||||
public class LiveEncounterConfiguration : IEntityTypeConfiguration<LiveEncounter>
|
||||
{
|
||||
public void Configure(EntityTypeBuilder<LiveEncounter> builder)
|
||||
{
|
||||
builder.ToTable("live_encounters", t =>
|
||||
{
|
||||
t.HasCheckConstraint("chk_live_encounters_department",
|
||||
"department IS NULL OR department IN ('Emergency Department', 'Internal Medicine', 'General Medicine', 'Surgery', 'ICU', 'NICU', 'Medical-Surgical', 'Outpatient Clinic', 'Pediatrics', 'Obstetrics & Gynecology', 'Labor & Delivery', 'Cardiology', 'Orthopedics', 'Neurology', 'Oncology', 'Radiology', 'Laboratory', 'Psychiatry', 'Physical Therapy', 'Anesthesiology')");
|
||||
});
|
||||
builder.HasKey(e => e.Id);
|
||||
builder.Property(e => e.Id).HasColumnName("id").HasDefaultValueSql("gen_random_uuid()");
|
||||
builder.Property(e => e.PatientId).HasColumnName("patient_id").IsRequired();
|
||||
builder.Property(e => e.AdmissionDate).HasColumnName("admission_date").IsRequired();
|
||||
builder.Property(e => e.Department)
|
||||
.HasColumnName("department")
|
||||
.HasMaxLength(100)
|
||||
.HasConversion(
|
||||
v => v.HasValue ? v.Value.ToDbString() : null,
|
||||
v => v == null ? null : DepartmentExtensions.FromDbString(v));
|
||||
builder.Property(e => e.RoomBed).HasColumnName("room_bed").HasMaxLength(50);
|
||||
builder.Property(e => e.AdmissionReason).HasColumnName("admission_reason").HasMaxLength(500);
|
||||
builder.Property(e => e.DischargeDiagnosis).HasColumnName("discharge_diagnosis").HasMaxLength(500);
|
||||
builder.Property(e => e.Status).HasColumnName("status").HasMaxLength(20).IsRequired();
|
||||
builder.Property(e => e.CreatedAt).HasColumnName("created_at").HasDefaultValueSql("NOW()");
|
||||
|
||||
builder.HasOne<Patient>()
|
||||
.WithMany()
|
||||
.HasForeignKey(e => e.PatientId)
|
||||
.OnDelete(DeleteBehavior.Restrict);
|
||||
|
||||
builder.HasIndex(e => new { e.PatientId, e.Status });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.EntityFrameworkCore.Metadata.Builders;
|
||||
|
||||
public class LiveObservationConfiguration : IEntityTypeConfiguration<LiveObservation>
|
||||
{
|
||||
public void Configure(EntityTypeBuilder<LiveObservation> builder)
|
||||
{
|
||||
builder.ToTable("live_observations");
|
||||
builder.HasKey(o => o.Id);
|
||||
builder.Property(o => o.Id).HasColumnName("id").HasDefaultValueSql("gen_random_uuid()");
|
||||
builder.Property(o => o.EncounterId).HasColumnName("encounter_id").IsRequired();
|
||||
builder.Property(o => o.PatientId).HasColumnName("patient_id");
|
||||
builder.Property(o => o.SourceBatchId).HasColumnName("source_batch_id").IsRequired();
|
||||
builder.Property(o => o.ObservationCode).HasColumnName("observation_code").HasMaxLength(50).IsRequired();
|
||||
builder.Property(o => o.Value).HasColumnName("value").HasColumnType("decimal(10,3)").IsRequired();
|
||||
builder.Property(o => o.Unit).HasColumnName("unit").HasMaxLength(20).IsRequired();
|
||||
builder.Property(o => o.RecordedAt).HasColumnName("recorded_at").IsRequired();
|
||||
builder.Property(o => o.Note).HasColumnName("note").HasMaxLength(500);
|
||||
builder.Property(o => o.CreatedAt).HasColumnName("created_at").HasDefaultValueSql("NOW()");
|
||||
|
||||
// Phase 5: Supersession columns
|
||||
builder.Property(o => o.IsSuperseded).HasColumnName("is_superseded").HasDefaultValue(false);
|
||||
builder.Property(o => o.SupersededByBatchId).HasColumnName("superseded_by_batch_id");
|
||||
builder.Property(o => o.SupersededAt).HasColumnName("superseded_at");
|
||||
|
||||
builder.HasOne(o => o.Encounter)
|
||||
.WithMany(e => e.Observations)
|
||||
.HasForeignKey(o => o.EncounterId)
|
||||
.OnDelete(DeleteBehavior.Restrict);
|
||||
|
||||
builder.HasIndex(o => new { o.EncounterId, o.ObservationCode });
|
||||
builder.HasIndex(o => o.SourceBatchId);
|
||||
builder.HasIndex(o => o.IsSuperseded).HasFilter("is_superseded = true");
|
||||
}
|
||||
}
|
||||
Generated
+1229
File diff suppressed because it is too large
Load Diff
+102
@@ -0,0 +1,102 @@
|
||||
using System;
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace VigilCareRecordsAPI.Data.Migrations
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public partial class AddLiveEncountersAndObservations : Migration
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.CreateTable(
|
||||
name: "live_encounters",
|
||||
columns: table => new
|
||||
{
|
||||
id = table.Column<Guid>(type: "uuid", nullable: false, defaultValueSql: "gen_random_uuid()"),
|
||||
patient_id = table.Column<Guid>(type: "uuid", nullable: false),
|
||||
admission_date = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: false),
|
||||
department = table.Column<string>(type: "character varying(100)", maxLength: 100, nullable: true),
|
||||
room_bed = table.Column<string>(type: "character varying(50)", maxLength: 50, nullable: true),
|
||||
admission_reason = table.Column<string>(type: "character varying(500)", maxLength: 500, nullable: true),
|
||||
discharge_diagnosis = table.Column<string>(type: "character varying(500)", maxLength: 500, nullable: true),
|
||||
status = table.Column<string>(type: "character varying(20)", maxLength: 20, nullable: false),
|
||||
created_at = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: false, defaultValueSql: "NOW()")
|
||||
},
|
||||
constraints: table =>
|
||||
{
|
||||
table.PrimaryKey("PK_live_encounters", x => x.id);
|
||||
table.CheckConstraint("chk_live_encounters_department", "department IS NULL OR department IN ('Emergency Department', 'Internal Medicine', 'General Medicine', 'Surgery', 'ICU', 'NICU', 'Medical-Surgical', 'Outpatient Clinic', 'Pediatrics', 'Obstetrics & Gynecology', 'Labor & Delivery', 'Cardiology', 'Orthopedics', 'Neurology', 'Oncology', 'Radiology', 'Laboratory', 'Psychiatry', 'Physical Therapy', 'Anesthesiology')");
|
||||
table.ForeignKey(
|
||||
name: "FK_live_encounters_patients_patient_id",
|
||||
column: x => x.patient_id,
|
||||
principalSchema: "clinical",
|
||||
principalTable: "patients",
|
||||
principalColumn: "id",
|
||||
onDelete: ReferentialAction.Restrict);
|
||||
});
|
||||
|
||||
migrationBuilder.CreateTable(
|
||||
name: "live_observations",
|
||||
columns: table => new
|
||||
{
|
||||
id = table.Column<Guid>(type: "uuid", nullable: false, defaultValueSql: "gen_random_uuid()"),
|
||||
encounter_id = table.Column<Guid>(type: "uuid", nullable: false),
|
||||
patient_id = table.Column<Guid>(type: "uuid", nullable: true),
|
||||
source_batch_id = table.Column<Guid>(type: "uuid", nullable: false),
|
||||
observation_code = table.Column<string>(type: "character varying(50)", maxLength: 50, nullable: false),
|
||||
value = table.Column<decimal>(type: "numeric(10,3)", nullable: false),
|
||||
unit = table.Column<string>(type: "character varying(20)", maxLength: 20, nullable: false),
|
||||
recorded_at = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: false),
|
||||
note = table.Column<string>(type: "character varying(500)", maxLength: 500, nullable: true),
|
||||
created_at = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: false, defaultValueSql: "NOW()"),
|
||||
is_superseded = table.Column<bool>(type: "boolean", nullable: false, defaultValue: false),
|
||||
superseded_by_batch_id = table.Column<Guid>(type: "uuid", nullable: true),
|
||||
superseded_at = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: true)
|
||||
},
|
||||
constraints: table =>
|
||||
{
|
||||
table.PrimaryKey("PK_live_observations", x => x.id);
|
||||
table.ForeignKey(
|
||||
name: "FK_live_observations_live_encounters_encounter_id",
|
||||
column: x => x.encounter_id,
|
||||
principalTable: "live_encounters",
|
||||
principalColumn: "id",
|
||||
onDelete: ReferentialAction.Restrict);
|
||||
});
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_live_encounters_patient_id_status",
|
||||
table: "live_encounters",
|
||||
columns: new[] { "patient_id", "status" });
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_live_observations_encounter_id_observation_code",
|
||||
table: "live_observations",
|
||||
columns: new[] { "encounter_id", "observation_code" });
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_live_observations_is_superseded",
|
||||
table: "live_observations",
|
||||
column: "is_superseded",
|
||||
filter: "is_superseded = true");
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_live_observations_source_batch_id",
|
||||
table: "live_observations",
|
||||
column: "source_batch_id");
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.DropTable(
|
||||
name: "live_observations");
|
||||
|
||||
migrationBuilder.DropTable(
|
||||
name: "live_encounters");
|
||||
}
|
||||
}
|
||||
}
|
||||
Generated
+1229
File diff suppressed because it is too large
Load Diff
+22
@@ -0,0 +1,22 @@
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace VigilCareRecordsAPI.Data.Migrations
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public partial class AddLiveObservationAndLiveEncounter : Migration
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -547,6 +547,141 @@ namespace VigilCareRecordsAPI.Data.Migrations
|
||||
b.ToTable("idempotency_records", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("LiveEncounter", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("uuid")
|
||||
.HasColumnName("id")
|
||||
.HasDefaultValueSql("gen_random_uuid()");
|
||||
|
||||
b.Property<DateTimeOffset>("AdmissionDate")
|
||||
.HasColumnType("timestamp with time zone")
|
||||
.HasColumnName("admission_date");
|
||||
|
||||
b.Property<string>("AdmissionReason")
|
||||
.HasMaxLength(500)
|
||||
.HasColumnType("character varying(500)")
|
||||
.HasColumnName("admission_reason");
|
||||
|
||||
b.Property<DateTimeOffset>("CreatedAt")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("timestamp with time zone")
|
||||
.HasColumnName("created_at")
|
||||
.HasDefaultValueSql("NOW()");
|
||||
|
||||
b.Property<string>("Department")
|
||||
.HasMaxLength(100)
|
||||
.HasColumnType("character varying(100)")
|
||||
.HasColumnName("department");
|
||||
|
||||
b.Property<string>("DischargeDiagnosis")
|
||||
.HasMaxLength(500)
|
||||
.HasColumnType("character varying(500)")
|
||||
.HasColumnName("discharge_diagnosis");
|
||||
|
||||
b.Property<Guid>("PatientId")
|
||||
.HasColumnType("uuid")
|
||||
.HasColumnName("patient_id");
|
||||
|
||||
b.Property<string>("RoomBed")
|
||||
.HasMaxLength(50)
|
||||
.HasColumnType("character varying(50)")
|
||||
.HasColumnName("room_bed");
|
||||
|
||||
b.Property<string>("Status")
|
||||
.IsRequired()
|
||||
.HasMaxLength(20)
|
||||
.HasColumnType("character varying(20)")
|
||||
.HasColumnName("status");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("PatientId", "Status");
|
||||
|
||||
b.ToTable("live_encounters", null, t =>
|
||||
{
|
||||
t.HasCheckConstraint("chk_live_encounters_department", "department IS NULL OR department IN ('Emergency Department', 'Internal Medicine', 'General Medicine', 'Surgery', 'ICU', 'NICU', 'Medical-Surgical', 'Outpatient Clinic', 'Pediatrics', 'Obstetrics & Gynecology', 'Labor & Delivery', 'Cardiology', 'Orthopedics', 'Neurology', 'Oncology', 'Radiology', 'Laboratory', 'Psychiatry', 'Physical Therapy', 'Anesthesiology')");
|
||||
});
|
||||
});
|
||||
|
||||
modelBuilder.Entity("LiveObservation", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("uuid")
|
||||
.HasColumnName("id")
|
||||
.HasDefaultValueSql("gen_random_uuid()");
|
||||
|
||||
b.Property<DateTimeOffset>("CreatedAt")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("timestamp with time zone")
|
||||
.HasColumnName("created_at")
|
||||
.HasDefaultValueSql("NOW()");
|
||||
|
||||
b.Property<Guid>("EncounterId")
|
||||
.HasColumnType("uuid")
|
||||
.HasColumnName("encounter_id");
|
||||
|
||||
b.Property<bool>("IsSuperseded")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("boolean")
|
||||
.HasDefaultValue(false)
|
||||
.HasColumnName("is_superseded");
|
||||
|
||||
b.Property<string>("Note")
|
||||
.HasMaxLength(500)
|
||||
.HasColumnType("character varying(500)")
|
||||
.HasColumnName("note");
|
||||
|
||||
b.Property<string>("ObservationCode")
|
||||
.IsRequired()
|
||||
.HasMaxLength(50)
|
||||
.HasColumnType("character varying(50)")
|
||||
.HasColumnName("observation_code");
|
||||
|
||||
b.Property<Guid?>("PatientId")
|
||||
.HasColumnType("uuid")
|
||||
.HasColumnName("patient_id");
|
||||
|
||||
b.Property<DateTimeOffset>("RecordedAt")
|
||||
.HasColumnType("timestamp with time zone")
|
||||
.HasColumnName("recorded_at");
|
||||
|
||||
b.Property<Guid>("SourceBatchId")
|
||||
.HasColumnType("uuid")
|
||||
.HasColumnName("source_batch_id");
|
||||
|
||||
b.Property<DateTimeOffset?>("SupersededAt")
|
||||
.HasColumnType("timestamp with time zone")
|
||||
.HasColumnName("superseded_at");
|
||||
|
||||
b.Property<Guid?>("SupersededByBatchId")
|
||||
.HasColumnType("uuid")
|
||||
.HasColumnName("superseded_by_batch_id");
|
||||
|
||||
b.Property<string>("Unit")
|
||||
.IsRequired()
|
||||
.HasMaxLength(20)
|
||||
.HasColumnType("character varying(20)")
|
||||
.HasColumnName("unit");
|
||||
|
||||
b.Property<decimal>("Value")
|
||||
.HasColumnType("decimal(10,3)")
|
||||
.HasColumnName("value");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("IsSuperseded")
|
||||
.HasFilter("is_superseded = true");
|
||||
|
||||
b.HasIndex("SourceBatchId");
|
||||
|
||||
b.HasIndex("EncounterId", "ObservationCode");
|
||||
|
||||
b.ToTable("live_observations", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Observation", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
@@ -1000,6 +1135,26 @@ namespace VigilCareRecordsAPI.Data.Migrations
|
||||
b.Navigation("Patient");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("LiveEncounter", b =>
|
||||
{
|
||||
b.HasOne("Patient", null)
|
||||
.WithMany()
|
||||
.HasForeignKey("PatientId")
|
||||
.OnDelete(DeleteBehavior.Restrict)
|
||||
.IsRequired();
|
||||
});
|
||||
|
||||
modelBuilder.Entity("LiveObservation", b =>
|
||||
{
|
||||
b.HasOne("LiveEncounter", "Encounter")
|
||||
.WithMany("Observations")
|
||||
.HasForeignKey("EncounterId")
|
||||
.OnDelete(DeleteBehavior.Restrict)
|
||||
.IsRequired();
|
||||
|
||||
b.Navigation("Encounter");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Observation", b =>
|
||||
{
|
||||
b.HasOne("Encounter", "Encounter")
|
||||
@@ -1060,6 +1215,11 @@ namespace VigilCareRecordsAPI.Data.Migrations
|
||||
|
||||
b.Navigation("Events");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("LiveEncounter", b =>
|
||||
{
|
||||
b.Navigation("Observations");
|
||||
});
|
||||
#pragma warning restore 612, 618
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,14 @@
|
||||
public class LiveEncounter
|
||||
{
|
||||
public Guid Id { get; set; }
|
||||
public Guid PatientId { get; set; }
|
||||
public DateTimeOffset AdmissionDate { get; set; }
|
||||
public Department? Department { get; set; }
|
||||
public string? RoomBed { get; set; }
|
||||
public string? AdmissionReason { get; set; }
|
||||
public string? DischargeDiagnosis { get; set; }
|
||||
public string Status { get; set; } = "active";
|
||||
public DateTimeOffset CreatedAt { get; set; }
|
||||
|
||||
public ICollection<LiveObservation> Observations { get; set; } = new List<LiveObservation>();
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
public class LiveObservation
|
||||
{
|
||||
public Guid Id { get; set; }
|
||||
public Guid EncounterId { get; set; }
|
||||
public Guid? PatientId { get; set; }
|
||||
public Guid SourceBatchId { get; set; }
|
||||
public string ObservationCode { get; set; } = null!;
|
||||
public decimal Value { get; set; }
|
||||
public string Unit { get; set; } = null!;
|
||||
public DateTimeOffset RecordedAt { get; set; }
|
||||
public string? Note { get; set; }
|
||||
public DateTimeOffset CreatedAt { get; set; }
|
||||
|
||||
// Phase 5: Supersession fields
|
||||
public bool IsSuperseded { get; set; }
|
||||
public Guid? SupersededByBatchId { get; set; }
|
||||
public DateTimeOffset? SupersededAt { get; set; }
|
||||
|
||||
public LiveEncounter Encounter { get; set; } = null!;
|
||||
}
|
||||
@@ -19,11 +19,16 @@ public record BatchDetailResponse(
|
||||
Guid? PromotionEncounterId,
|
||||
Guid? SupersedesBatchId,
|
||||
bool ClinicianAttestation,
|
||||
bool IsCorrection,
|
||||
SupersessionInfo? Supersession,
|
||||
DateTimeOffset CreatedAt,
|
||||
DateTimeOffset UpdatedAt
|
||||
)
|
||||
{
|
||||
public static BatchDetailResponse FromEntity(DigitizationBatch batch, string? documentUrl = null) =>
|
||||
public static BatchDetailResponse FromEntity(
|
||||
DigitizationBatch batch,
|
||||
string? documentUrl = null,
|
||||
SupersessionInfo? supersession = null) =>
|
||||
new(
|
||||
batch.Id,
|
||||
batch.Status.ToDbString(),
|
||||
@@ -41,7 +46,19 @@ public record BatchDetailResponse(
|
||||
batch.PromotionEncounterId,
|
||||
batch.SupersedesBatchId,
|
||||
batch.ClinicianAttestation,
|
||||
batch.SupersedesBatchId.HasValue,
|
||||
supersession,
|
||||
batch.CreatedAt,
|
||||
batch.UpdatedAt
|
||||
);
|
||||
|
||||
public static SupersessionInfo ToSupersessionInfo(
|
||||
DigitizationBatch supersededBatch,
|
||||
int originalObservationCount) =>
|
||||
new(
|
||||
supersededBatch.Id,
|
||||
supersededBatch.Status.ToDbString(),
|
||||
supersededBatch.PromotedAt ?? supersededBatch.UpdatedAt,
|
||||
originalObservationCount
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,24 +1,19 @@
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
|
||||
/// <summary>
|
||||
/// Multipart form for batch creation. Combines the uploaded scan with batch metadata
|
||||
/// so Swagger documents a single <c>multipart/form-data</c> request.
|
||||
/// </summary>
|
||||
public class CreateBatchForm
|
||||
{
|
||||
/// <summary>PDF, JPEG, or PNG scan (max 25 MB).</summary>
|
||||
[Required]
|
||||
public IFormFile File { get; set; } = null!;
|
||||
|
||||
/// <summary>Batch type literal, e.g. PATIENT_REGISTRATION or ENCOUNTER_SUMMARY.</summary>
|
||||
[Required]
|
||||
public string BatchType { get; set; } = null!;
|
||||
|
||||
/// <summary>BACKFILL (default) or LIVE_CAPTURE.</summary>
|
||||
public string? Track { get; set; }
|
||||
|
||||
/// <summary>Optional patient link used for duplicate-document detection.</summary>
|
||||
public Guid? PatientId { get; set; }
|
||||
|
||||
public CreateBatchRequest ToMetadata() => new(BatchType, Track, PatientId);
|
||||
}
|
||||
public Guid? SupersedesBatchId { get; set; }
|
||||
|
||||
public CreateBatchRequest ToMetadata() =>
|
||||
new(BatchType, Track, PatientId, SupersedesBatchId);
|
||||
}
|
||||
@@ -4,5 +4,6 @@
|
||||
public record CreateBatchRequest(
|
||||
string BatchType,
|
||||
string? Track,
|
||||
Guid? PatientId
|
||||
Guid? PatientId,
|
||||
Guid? SupersedesBatchId
|
||||
);
|
||||
|
||||
@@ -0,0 +1,4 @@
|
||||
public record CreateBatchResult(
|
||||
DigitizationBatch Batch,
|
||||
SupersessionInfo? Supersession
|
||||
);
|
||||
@@ -0,0 +1,7 @@
|
||||
public record DigitizationEventSummary(
|
||||
string EventType,
|
||||
DateTimeOffset OccurredAt,
|
||||
Guid ActorUserId,
|
||||
string ActorName,
|
||||
string? MetadataJson
|
||||
);
|
||||
@@ -0,0 +1,23 @@
|
||||
public record DigitizationHistoryEntry(
|
||||
Guid BatchId,
|
||||
string Status,
|
||||
string BatchType,
|
||||
string Track,
|
||||
Guid? SupersedesBatchId,
|
||||
bool IsCorrection,
|
||||
bool HasBeenSuperseded,
|
||||
Guid? SupersededByBatchId,
|
||||
int DraftObservationCount,
|
||||
int LiveObservationCount,
|
||||
int SupersededObservationCount,
|
||||
DateTimeOffset CreatedAt,
|
||||
DateTimeOffset? PromotedAt,
|
||||
Guid? PromotionEncounterId,
|
||||
Guid? EnteredByUserId,
|
||||
string? EnteredByUserName,
|
||||
Guid? VerifiedByUserId,
|
||||
string? VerifiedByUserName,
|
||||
Guid? ApprovedByUserId,
|
||||
string? ApprovedByUserName,
|
||||
IReadOnlyList<DigitizationEventSummary> AuditTrail
|
||||
);
|
||||
@@ -0,0 +1,8 @@
|
||||
public record PatientDigitizationHistoryResponse(
|
||||
Guid PatientId,
|
||||
int TotalBatches,
|
||||
int PromotedBatches,
|
||||
int SupersededBatches,
|
||||
int PendingBatches,
|
||||
IReadOnlyList<DigitizationHistoryEntry> Entries
|
||||
);
|
||||
@@ -0,0 +1,6 @@
|
||||
public record SupersessionInfo(
|
||||
Guid OriginalBatchId,
|
||||
string OriginalBatchStatus,
|
||||
DateTimeOffset OriginalPromotedAt,
|
||||
int OriginalObservationCount
|
||||
);
|
||||
@@ -0,0 +1,7 @@
|
||||
public record PromotionResult(
|
||||
Guid BatchId,
|
||||
Guid EncounterId,
|
||||
int ObservationsPromoted,
|
||||
bool IsCorrection,
|
||||
SupersessionResult? Supersession
|
||||
);
|
||||
@@ -0,0 +1,6 @@
|
||||
public record SupersessionResult(
|
||||
Guid OriginalBatchId,
|
||||
int ObservationsSuperseded,
|
||||
int ObservationsReplaced,
|
||||
DateTimeOffset SupersededAt
|
||||
);
|
||||
@@ -65,6 +65,7 @@ try
|
||||
builder.Services.AddScoped<IPromotionService, PromotionService>();
|
||||
builder.Services.AddScoped<IIdempotencyService, IdempotencyService>();
|
||||
builder.Services.AddScoped<IMrnGenerator, MrnGenerator>();
|
||||
builder.Services.AddScoped<IDigitizationHistoryService, DigitizationHistoryService>();
|
||||
|
||||
builder.Services.AddControllers();
|
||||
builder.Services.AddEndpointsApiExplorer();
|
||||
@@ -89,8 +90,9 @@ try
|
||||
app.UseAuthorization();
|
||||
app.MapControllers();
|
||||
|
||||
using (var scope = app.Services.CreateScope())
|
||||
if (!app.Environment.IsEnvironment("Testing"))
|
||||
{
|
||||
using var scope = app.Services.CreateScope();
|
||||
var db = scope.ServiceProvider.GetRequiredService<AppDbContext>();
|
||||
await db.Database.MigrateAsync();
|
||||
await DataSeeder.SeedAsync(db);
|
||||
|
||||
@@ -30,14 +30,47 @@ public class BatchService : IBatchService
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
public async Task<DigitizationBatch> CreateAsync(
|
||||
public async Task<CreateBatchResult> CreateAsync(
|
||||
Stream fileStream, string contentType, BatchType batchType,
|
||||
BatchTrack track, Guid? patientId, Guid actorUserId)
|
||||
BatchTrack track, Guid? patientId, Guid? supersedesBatchId, Guid actorUserId)
|
||||
{
|
||||
// Supersession validation: target batch must exist and be in Promoted status
|
||||
DigitizationBatch? supersededBatch = null;
|
||||
if (supersedesBatchId.HasValue)
|
||||
{
|
||||
supersededBatch = await _db.DigitizationBatches
|
||||
.FirstOrDefaultAsync(b => b.Id == supersedesBatchId.Value);
|
||||
|
||||
if (supersededBatch is null)
|
||||
throw new NotFoundException(
|
||||
$"Batch {supersedesBatchId.Value} not found.",
|
||||
"SUPERSEDED_BATCH_NOT_FOUND");
|
||||
|
||||
if (supersededBatch.Status != BatchStatus.Promoted)
|
||||
throw new ValidationException(
|
||||
"Only promoted batches can be superseded. " +
|
||||
$"Batch {supersedesBatchId.Value} is in '{supersededBatch.Status.ToDbString()}' status.",
|
||||
"SUPERSEDED_BATCH_NOT_PROMOTED");
|
||||
|
||||
// Prevent supersession chains: the target batch must not itself be a correction
|
||||
// that has already been superseded by another promoted correction
|
||||
var existingCorrection = await _db.DigitizationBatches
|
||||
.AnyAsync(b => b.SupersedesBatchId == supersedesBatchId.Value
|
||||
&& b.Status == BatchStatus.Promoted);
|
||||
|
||||
if (existingCorrection)
|
||||
throw new ConflictException(
|
||||
$"Batch {supersedesBatchId.Value} has already been superseded by a promoted correction. " +
|
||||
"Create a new correction against the latest promoted batch instead.",
|
||||
"BATCH_ALREADY_SUPERSEDED");
|
||||
|
||||
// Inherit patient context from the superseded batch
|
||||
patientId ??= supersededBatch.PatientId;
|
||||
}
|
||||
|
||||
var (objectKey, sha256, fileSize) = await _storage.UploadAsync(fileStream, contentType, Guid.NewGuid());
|
||||
|
||||
// Duplicate detection: same SHA-256 for same patient within 24 hours.
|
||||
// Cross-patient duplicates (same form scanned for two patients) are allowed.
|
||||
// Duplicate detection: same SHA-256 for same patient within 24 hours
|
||||
if (patientId.HasValue)
|
||||
{
|
||||
var cutoff = DateTimeOffset.UtcNow.AddHours(-24);
|
||||
@@ -63,6 +96,7 @@ public class BatchService : IBatchService
|
||||
DocumentRef = objectKey,
|
||||
DocumentSha256 = sha256,
|
||||
EnableRetroactiveAlerts = false,
|
||||
SupersedesBatchId = supersedesBatchId,
|
||||
CreatedAt = DateTimeOffset.UtcNow,
|
||||
UpdatedAt = DateTimeOffset.UtcNow
|
||||
};
|
||||
@@ -78,13 +112,21 @@ public class BatchService : IBatchService
|
||||
UploadedAt = DateTimeOffset.UtcNow
|
||||
};
|
||||
|
||||
var eventType = supersedesBatchId.HasValue
|
||||
? DigitizationEventType.CorrectionUploaded
|
||||
: DigitizationEventType.Uploaded;
|
||||
var eventMetadata = supersedesBatchId.HasValue
|
||||
? JsonSerializer.Serialize(new { supersedesBatchId = supersedesBatchId.Value })
|
||||
: null;
|
||||
|
||||
var evt = new DigitizationEvent
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
BatchId = batchId,
|
||||
EventType = DigitizationEventType.Uploaded,
|
||||
EventType = eventType,
|
||||
ActorUserId = actorUserId,
|
||||
OccurredAt = DateTimeOffset.UtcNow
|
||||
OccurredAt = DateTimeOffset.UtcNow,
|
||||
MetadataJson = eventMetadata
|
||||
};
|
||||
|
||||
_db.DigitizationBatches.Add(batch);
|
||||
@@ -92,8 +134,19 @@ public class BatchService : IBatchService
|
||||
_db.DigitizationEvents.Add(evt);
|
||||
await _db.SaveChangesAsync();
|
||||
|
||||
_logger.LogInformation("Batch {BatchId} created with document {ObjectKey}", batchId, objectKey);
|
||||
return batch;
|
||||
_logger.LogInformation(
|
||||
"Batch {BatchId} created (correction={IsCorrection}, supersedes={SupersedesBatchId})",
|
||||
batchId, supersedesBatchId.HasValue, supersedesBatchId);
|
||||
|
||||
SupersessionInfo? supersession = null;
|
||||
if (supersededBatch is not null)
|
||||
{
|
||||
var obsCount = await _db.Observations
|
||||
.CountAsync(o => o.SourceBatchId == supersededBatch.Id);
|
||||
supersession = BatchDetailResponse.ToSupersessionInfo(supersededBatch, obsCount);
|
||||
}
|
||||
|
||||
return new CreateBatchResult(batch, supersession);
|
||||
}
|
||||
|
||||
public async Task<DigitizationBatch> GetByIdAsync(Guid id)
|
||||
|
||||
@@ -0,0 +1,122 @@
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
public class DigitizationHistoryService : IDigitizationHistoryService
|
||||
{
|
||||
private readonly AppDbContext _db;
|
||||
|
||||
public DigitizationHistoryService(AppDbContext db)
|
||||
{
|
||||
_db = db;
|
||||
}
|
||||
|
||||
public async Task<PatientDigitizationHistoryResponse> GetPatientHistoryAsync(Guid patientId)
|
||||
{
|
||||
// Verify the patient exists (has at least one batch)
|
||||
var hasBatches = await _db.DigitizationBatches
|
||||
.AnyAsync(b => b.PatientId == patientId);
|
||||
|
||||
if (!hasBatches)
|
||||
throw new NotFoundException(
|
||||
$"No digitization history found for patient {patientId}.",
|
||||
"PATIENT_HISTORY_NOT_FOUND");
|
||||
|
||||
// Load all batches for this patient with their relationships
|
||||
var batches = await _db.DigitizationBatches
|
||||
.Include(b => b.DraftObservations)
|
||||
.Include(b => b.Events)
|
||||
.ThenInclude(e => e.Actor)
|
||||
.Include(b => b.EnteredByUser)
|
||||
.Include(b => b.VerifiedByUser)
|
||||
.Include(b => b.ApprovedByUser)
|
||||
.Where(b => b.PatientId == patientId)
|
||||
.OrderByDescending(b => b.CreatedAt)
|
||||
.ToListAsync();
|
||||
|
||||
// For each promoted batch, count live vs superseded observations
|
||||
var promotedBatchIds = batches
|
||||
.Where(b => b.Status == BatchStatus.Promoted)
|
||||
.Select(b => b.Id)
|
||||
.ToList();
|
||||
|
||||
var liveObservationCounts = await _db.LiveObservations
|
||||
.Where(o => promotedBatchIds.Contains(o.SourceBatchId))
|
||||
.GroupBy(o => new { o.SourceBatchId, o.IsSuperseded })
|
||||
.Select(g => new
|
||||
{
|
||||
g.Key.SourceBatchId,
|
||||
g.Key.IsSuperseded,
|
||||
Count = g.Count()
|
||||
})
|
||||
.ToListAsync();
|
||||
|
||||
// Build a lookup: batchId -> (activeCount, supersededCount)
|
||||
var obsCountLookup = promotedBatchIds.ToDictionary(
|
||||
id => id,
|
||||
id =>
|
||||
{
|
||||
var active = liveObservationCounts
|
||||
.FirstOrDefault(x => x.SourceBatchId == id && !x.IsSuperseded)?.Count ?? 0;
|
||||
var superseded = liveObservationCounts
|
||||
.FirstOrDefault(x => x.SourceBatchId == id && x.IsSuperseded)?.Count ?? 0;
|
||||
return (Active: active, Superseded: superseded);
|
||||
});
|
||||
|
||||
// Build a lookup for "has been superseded" — batches that appear as
|
||||
// SupersedesBatchId on another batch that reached Promoted
|
||||
var supersededByLookup = await _db.DigitizationBatches
|
||||
.Where(b => b.SupersedesBatchId.HasValue
|
||||
&& b.Status == BatchStatus.Promoted
|
||||
&& promotedBatchIds.Contains(b.SupersedesBatchId.Value))
|
||||
.ToDictionaryAsync(
|
||||
b => b.SupersedesBatchId!.Value,
|
||||
b => b.Id);
|
||||
|
||||
var entries = batches.Select(b =>
|
||||
{
|
||||
var counts = obsCountLookup.GetValueOrDefault(b.Id, (Active: 0, Superseded: 0));
|
||||
var hasBeenSuperseded = supersededByLookup.ContainsKey(b.Id);
|
||||
supersededByLookup.TryGetValue(b.Id, out var supersededByBatchId);
|
||||
|
||||
return new DigitizationHistoryEntry(
|
||||
BatchId: b.Id,
|
||||
Status: b.Status.ToDbString(),
|
||||
BatchType: b.BatchType.ToDbString(),
|
||||
Track: b.Track.ToDbString(),
|
||||
SupersedesBatchId: b.SupersedesBatchId,
|
||||
IsCorrection: b.SupersedesBatchId.HasValue,
|
||||
HasBeenSuperseded: hasBeenSuperseded,
|
||||
SupersededByBatchId: hasBeenSuperseded ? supersededByBatchId : null,
|
||||
DraftObservationCount: b.DraftObservations.Count,
|
||||
LiveObservationCount: counts.Active,
|
||||
SupersededObservationCount: counts.Superseded,
|
||||
CreatedAt: b.CreatedAt,
|
||||
PromotedAt: b.PromotedAt,
|
||||
PromotionEncounterId: b.PromotionEncounterId,
|
||||
EnteredByUserId: b.EnteredByUserId,
|
||||
EnteredByUserName: b.EnteredByUser?.FullName,
|
||||
VerifiedByUserId: b.VerifiedByUserId,
|
||||
VerifiedByUserName: b.VerifiedByUser?.FullName,
|
||||
ApprovedByUserId: b.ApprovedByUserId,
|
||||
ApprovedByUserName: b.ApprovedByUser?.FullName,
|
||||
AuditTrail: b.Events
|
||||
.OrderBy(e => e.OccurredAt)
|
||||
.Select(e => new DigitizationEventSummary(
|
||||
e.EventType.ToDbString(),
|
||||
e.OccurredAt,
|
||||
e.ActorUserId,
|
||||
e.Actor?.FullName ?? "Unknown",
|
||||
e.MetadataJson))
|
||||
.ToList()
|
||||
);
|
||||
}).ToList();
|
||||
|
||||
return new PatientDigitizationHistoryResponse(
|
||||
PatientId: patientId,
|
||||
TotalBatches: entries.Count,
|
||||
PromotedBatches: entries.Count(e => e.Status == "PROMOTED"),
|
||||
SupersededBatches: entries.Count(e => e.HasBeenSuperseded),
|
||||
PendingBatches: entries.Count(e => e.Status != "PROMOTED" && e.Status != "REJECTED"),
|
||||
Entries: entries
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -453,12 +453,15 @@ public class DraftService : IDraftService
|
||||
private static void ValidateLabResults(
|
||||
DigitizationBatch batch, List<string> errors)
|
||||
{
|
||||
// Required: Linked patient, encounter, >= 1 lab observation code, recordedAt
|
||||
if (batch.DraftPatient is null)
|
||||
errors.Add("Linked patient is required for lab results");
|
||||
// Corrections inherit patient and encounter from the superseded promoted batch
|
||||
if (!batch.SupersedesBatchId.HasValue)
|
||||
{
|
||||
if (batch.DraftPatient is null)
|
||||
errors.Add("Linked patient is required for lab results");
|
||||
|
||||
if (batch.DraftEncounter is null)
|
||||
errors.Add("Encounter context is required for lab results");
|
||||
if (batch.DraftEncounter is null)
|
||||
errors.Add("Encounter context is required for lab results");
|
||||
}
|
||||
|
||||
if (batch.DraftObservations.Count == 0)
|
||||
errors.Add("At least one lab observation is required");
|
||||
|
||||
@@ -1,8 +1,22 @@
|
||||
public interface IBatchService
|
||||
{
|
||||
Task<DigitizationBatch> CreateAsync(Stream fileStream, string contentType, BatchType batchType, BatchTrack track, Guid? patientId, Guid actorUserId);
|
||||
Task<CreateBatchResult> CreateAsync(
|
||||
Stream fileStream,
|
||||
string contentType,
|
||||
BatchType batchType,
|
||||
BatchTrack track,
|
||||
Guid? patientId,
|
||||
Guid? supersedesBatchId,
|
||||
Guid actorUserId);
|
||||
|
||||
Task<DigitizationBatch> GetByIdAsync(Guid id);
|
||||
Task<PagedResult<DigitizationBatch>> ListAsync(BatchStatus? status, BatchType? batchType, Guid? assignedTo, BatchTrack? track, int page, int pageSize);
|
||||
|
||||
Task<PagedResult<DigitizationBatch>> ListAsync(
|
||||
BatchStatus? status, BatchType? batchType,
|
||||
Guid? assignedTo, BatchTrack? track,
|
||||
int page, int pageSize);
|
||||
|
||||
Task<DigitizationBatch> AssignAsync(Guid batchId, Guid entryClerkUserId, Guid actorUserId);
|
||||
|
||||
BatchStatus[] GetAllowedTransitions(BatchStatus current);
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
public interface IDigitizationHistoryService
|
||||
{
|
||||
Task<PatientDigitizationHistoryResponse> GetPatientHistoryAsync(Guid patientId);
|
||||
}
|
||||
@@ -20,4 +20,6 @@ public interface IPromotionService
|
||||
/// Returns the promotion result for an already-promoted batch.
|
||||
/// </summary>
|
||||
Task<PromotionResultResponse> GetPromotionResultAsync(Guid batchId);
|
||||
|
||||
Task<PromotionResult> PromoteAsync(Guid batchId, Guid actorUserId);
|
||||
}
|
||||
@@ -67,12 +67,21 @@ public class PromotionService : IPromotionService
|
||||
"Separation of duties: the verifier cannot also approve the same batch.",
|
||||
"SEPARATION_OF_DUTIES_VIOLATION");
|
||||
|
||||
// --- Validate draft data completeness ---
|
||||
if (batch.DraftPatient is null)
|
||||
throw new ValidationException("Batch has no draft patient data.", "MISSING_DRAFT_PATIENT");
|
||||
// --- Validate draft data completeness (corrections reuse the linked live patient) ---
|
||||
if (!batch.SupersedesBatchId.HasValue)
|
||||
{
|
||||
if (batch.DraftPatient is null)
|
||||
throw new ValidationException("Batch has no draft patient data.", "MISSING_DRAFT_PATIENT");
|
||||
|
||||
if (string.IsNullOrWhiteSpace(batch.DraftPatient.FullName))
|
||||
throw new ValidationException("Draft patient must have a full name.", "INVALID_DRAFT_PATIENT");
|
||||
if (string.IsNullOrWhiteSpace(batch.DraftPatient.FullName))
|
||||
throw new ValidationException("Draft patient must have a full name.", "INVALID_DRAFT_PATIENT");
|
||||
}
|
||||
else if (!batch.PatientId.HasValue)
|
||||
{
|
||||
throw new ValidationException(
|
||||
"Correction batch has no linked patient.",
|
||||
"MISSING_PATIENT");
|
||||
}
|
||||
|
||||
// --- Begin atomic transaction ---
|
||||
await using var transaction = await _db.Database.BeginTransactionAsync();
|
||||
@@ -82,41 +91,79 @@ public class PromotionService : IPromotionService
|
||||
var now = DateTimeOffset.UtcNow;
|
||||
|
||||
// === Step 1: Create or update Patient ===
|
||||
var patient = await CreateOrUpdatePatientAsync(batch.DraftPatient, now);
|
||||
Patient patient;
|
||||
if (batch.SupersedesBatchId.HasValue)
|
||||
{
|
||||
patient = await _db.Patients.FirstOrDefaultAsync(p => p.Id == batch.PatientId!.Value)
|
||||
?? throw new NotFoundException(
|
||||
$"Patient {batch.PatientId.Value} not found.",
|
||||
"PATIENT_NOT_FOUND");
|
||||
}
|
||||
else
|
||||
{
|
||||
patient = await CreateOrUpdatePatientAsync(batch.DraftPatient!, now);
|
||||
}
|
||||
|
||||
// === Step 2: Create or match Encounter ===
|
||||
var encounter = await CreateOrMatchEncounterAsync(batch, patient.Id, now);
|
||||
// === Step 2: Create or match Encounter (reuse original encounter for corrections) ===
|
||||
var encounter = await ResolveClinicalEncounterAsync(batch, patient.Id, now);
|
||||
await EnsureLiveEncounterAsync(encounter, now);
|
||||
|
||||
// === Step 3: Insert each DraftObservation as live Observation ===
|
||||
var (observationIds, outboxCount) = await PromoteObservationsAsync(
|
||||
batch, patient.Id, encounter.Id, enableRetroactiveAlerts, now);
|
||||
|
||||
// === Step 3b: Mirror to live_observations for supersession / digitization history ===
|
||||
PromoteLiveObservationsAsync(batch, patient.Id, encounter.Id, now);
|
||||
|
||||
SupersessionResult? supersessionResult = null;
|
||||
if (batch.SupersedesBatchId.HasValue)
|
||||
{
|
||||
supersessionResult = await SupersedeOriginalBatchAsync(
|
||||
batch.SupersedesBatchId.Value, batchId, approverUserId, now);
|
||||
}
|
||||
|
||||
// === Step 4: Update batch status to Promoted ===
|
||||
batch.Status = BatchStatus.Promoted;
|
||||
batch.ApprovedByUserId = approverUserId;
|
||||
batch.PatientId = patient.Id;
|
||||
batch.PromotedAt = now;
|
||||
batch.PromotionEncounterId = encounter.Id;
|
||||
batch.EnableRetroactiveAlerts = enableRetroactiveAlerts;
|
||||
batch.UpdatedAt = now;
|
||||
|
||||
// === Step 5: Write DigitizationEvent ===
|
||||
var promotionMetadata = new Dictionary<string, object>
|
||||
{
|
||||
["patientId"] = patient.Id,
|
||||
["mrn"] = patient.Mrn,
|
||||
["encounterId"] = encounter.Id,
|
||||
["observationCount"] = observationIds.Length,
|
||||
["outboxEventsWritten"] = outboxCount,
|
||||
["enableRetroactiveAlerts"] = enableRetroactiveAlerts,
|
||||
["track"] = batch.Track.ToDbString(),
|
||||
["isCorrection"] = batch.SupersedesBatchId.HasValue
|
||||
};
|
||||
|
||||
if (supersessionResult is not null)
|
||||
{
|
||||
promotionMetadata["supersession"] = new
|
||||
{
|
||||
originalBatchId = supersessionResult.OriginalBatchId,
|
||||
observationsSuperseded = supersessionResult.ObservationsSuperseded,
|
||||
observationsReplaced = supersessionResult.ObservationsReplaced
|
||||
};
|
||||
}
|
||||
|
||||
_db.DigitizationEvents.Add(new DigitizationEvent
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
BatchId = batchId,
|
||||
EventType = DigitizationEventType.Promoted,
|
||||
EventType = batch.SupersedesBatchId.HasValue
|
||||
? DigitizationEventType.CorrectionPromoted
|
||||
: DigitizationEventType.Promoted,
|
||||
ActorUserId = approverUserId,
|
||||
OccurredAt = now,
|
||||
MetadataJson = JsonSerializer.Serialize(new
|
||||
{
|
||||
patientId = patient.Id,
|
||||
mrn = patient.Mrn,
|
||||
encounterId = encounter.Id,
|
||||
observationCount = observationIds.Length,
|
||||
outboxEventsWritten = outboxCount,
|
||||
enableRetroactiveAlerts,
|
||||
track = batch.Track.ToDbString()
|
||||
})
|
||||
MetadataJson = JsonSerializer.Serialize(promotionMetadata)
|
||||
});
|
||||
|
||||
// === Step 6: Store idempotency record (within same transaction) ===
|
||||
@@ -258,6 +305,80 @@ public class PromotionService : IPromotionService
|
||||
return patient;
|
||||
}
|
||||
|
||||
private async Task<Encounter> ResolveClinicalEncounterAsync(
|
||||
DigitizationBatch batch, Guid patientId, DateTimeOffset now)
|
||||
{
|
||||
if (batch.SupersedesBatchId.HasValue)
|
||||
{
|
||||
var originalBatch = await _db.DigitizationBatches
|
||||
.AsNoTracking()
|
||||
.FirstOrDefaultAsync(b => b.Id == batch.SupersedesBatchId.Value);
|
||||
|
||||
if (originalBatch?.PromotionEncounterId is not Guid encounterId)
|
||||
throw new ValidationException(
|
||||
"Original batch has no promotion encounter for correction reuse.",
|
||||
"MISSING_ORIGINAL_ENCOUNTER");
|
||||
|
||||
var encounter = await _db.Encounters.FirstOrDefaultAsync(e => e.Id == encounterId);
|
||||
if (encounter is null)
|
||||
throw new NotFoundException(
|
||||
"Original promotion encounter not found.",
|
||||
"PROMOTION_ENCOUNTER_NOT_FOUND");
|
||||
|
||||
_logger.LogInformation(
|
||||
"Correction batch {CorrectionBatchId} reusing clinical encounter {EncounterId} " +
|
||||
"from original batch {OriginalBatchId}",
|
||||
batch.Id, encounter.Id, originalBatch.Id);
|
||||
|
||||
return encounter;
|
||||
}
|
||||
|
||||
return await CreateOrMatchEncounterAsync(batch, patientId, now);
|
||||
}
|
||||
|
||||
private async Task EnsureLiveEncounterAsync(Encounter encounter, DateTimeOffset now)
|
||||
{
|
||||
if (await _db.LiveEncounters.AnyAsync(e => e.Id == encounter.Id))
|
||||
return;
|
||||
|
||||
_db.LiveEncounters.Add(new LiveEncounter
|
||||
{
|
||||
Id = encounter.Id,
|
||||
PatientId = encounter.PatientId,
|
||||
AdmissionDate = encounter.AdmissionDate ?? now,
|
||||
Department = encounter.Department,
|
||||
RoomBed = encounter.RoomBed,
|
||||
AdmissionReason = encounter.AdmissionReason,
|
||||
DischargeDiagnosis = encounter.DischargeDiagnosis,
|
||||
Status = encounter.Status,
|
||||
CreatedAt = now
|
||||
});
|
||||
}
|
||||
|
||||
private void PromoteLiveObservationsAsync(
|
||||
DigitizationBatch batch, Guid patientId, Guid encounterId, DateTimeOffset now)
|
||||
{
|
||||
foreach (var draft in batch.DraftObservations)
|
||||
{
|
||||
_db.LiveObservations.Add(new LiveObservation
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
EncounterId = encounterId,
|
||||
PatientId = patientId,
|
||||
SourceBatchId = batch.Id,
|
||||
ObservationCode = draft.ObservationCode,
|
||||
Value = draft.Value,
|
||||
Unit = draft.Unit,
|
||||
RecordedAt = draft.RecordedAt,
|
||||
Note = draft.Note,
|
||||
CreatedAt = now,
|
||||
IsSuperseded = false,
|
||||
SupersededByBatchId = null,
|
||||
SupersededAt = null
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
private async Task<Encounter> CreateOrMatchEncounterAsync(
|
||||
DigitizationBatch batch, Guid patientId, DateTimeOffset now)
|
||||
{
|
||||
@@ -389,4 +510,227 @@ public class PromotionService : IPromotionService
|
||||
|
||||
return (observationIds.ToArray(), outboxCount);
|
||||
}
|
||||
|
||||
public async Task<PromotionResult> PromoteAsync(Guid batchId, Guid actorUserId)
|
||||
{
|
||||
await using var transaction = await _db.Database.BeginTransactionAsync();
|
||||
|
||||
try
|
||||
{
|
||||
// Load the batch with all draft data
|
||||
var batch = await _db.DigitizationBatches
|
||||
.Include(b => b.DraftPatient)
|
||||
.Include(b => b.DraftEncounter)
|
||||
.Include(b => b.DraftObservations)
|
||||
.FirstOrDefaultAsync(b => b.Id == batchId);
|
||||
|
||||
if (batch is null)
|
||||
throw new NotFoundException("Batch not found.", "BATCH_NOT_FOUND");
|
||||
|
||||
if (batch.Status != BatchStatus.Approved)
|
||||
throw new ConflictException(
|
||||
$"Only approved batches can be promoted. Batch is in '{batch.Status.ToDbString()}' status.",
|
||||
"ILLEGAL_STATUS_TRANSITION");
|
||||
|
||||
// Resolve or create the live encounter
|
||||
var encounterId = await ResolveEncounterAsync(batch);
|
||||
|
||||
// Promote draft observations to live observations
|
||||
var now = DateTimeOffset.UtcNow;
|
||||
var liveObservations = batch.DraftObservations.Select(draft => new LiveObservation
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
EncounterId = encounterId,
|
||||
PatientId = batch.PatientId,
|
||||
SourceBatchId = batch.Id,
|
||||
ObservationCode = draft.ObservationCode,
|
||||
Value = draft.Value,
|
||||
Unit = draft.Unit,
|
||||
RecordedAt = draft.RecordedAt,
|
||||
Note = draft.Note,
|
||||
CreatedAt = now,
|
||||
IsSuperseded = false,
|
||||
SupersededByBatchId = null,
|
||||
SupersededAt = null
|
||||
}).ToList();
|
||||
|
||||
_db.LiveObservations.AddRange(liveObservations);
|
||||
|
||||
// Handle supersession if this is a correction batch
|
||||
SupersessionResult? supersessionResult = null;
|
||||
if (batch.SupersedesBatchId.HasValue)
|
||||
{
|
||||
supersessionResult = await SupersedeOriginalBatchAsync(
|
||||
batch.SupersedesBatchId.Value,
|
||||
batch.Id,
|
||||
actorUserId,
|
||||
now);
|
||||
}
|
||||
|
||||
// Update batch status to Promoted
|
||||
batch.Status = BatchStatus.Promoted;
|
||||
batch.PromotedAt = now;
|
||||
batch.PromotionEncounterId = encounterId;
|
||||
batch.UpdatedAt = now;
|
||||
|
||||
// Record promotion event on the correction batch
|
||||
var promotionMetadata = new Dictionary<string, object>
|
||||
{
|
||||
["encounterId"] = encounterId,
|
||||
["observationsPromoted"] = liveObservations.Count,
|
||||
["isCorrection"] = batch.SupersedesBatchId.HasValue
|
||||
};
|
||||
|
||||
if (supersessionResult is not null)
|
||||
{
|
||||
promotionMetadata["supersession"] = new
|
||||
{
|
||||
originalBatchId = supersessionResult.OriginalBatchId,
|
||||
observationsSuperseded = supersessionResult.ObservationsSuperseded,
|
||||
observationsReplaced = supersessionResult.ObservationsReplaced
|
||||
};
|
||||
}
|
||||
|
||||
_db.DigitizationEvents.Add(new DigitizationEvent
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
BatchId = batch.Id,
|
||||
EventType = batch.SupersedesBatchId.HasValue
|
||||
? DigitizationEventType.CorrectionPromoted
|
||||
: DigitizationEventType.Promoted,
|
||||
ActorUserId = actorUserId,
|
||||
OccurredAt = now,
|
||||
MetadataJson = JsonSerializer.Serialize(promotionMetadata)
|
||||
});
|
||||
|
||||
await _db.SaveChangesAsync();
|
||||
await transaction.CommitAsync();
|
||||
|
||||
_logger.LogInformation(
|
||||
"Batch {BatchId} promoted (correction={IsCorrection}, " +
|
||||
"observations={ObservationCount}, superseded={SupersededCount})",
|
||||
batchId,
|
||||
batch.SupersedesBatchId.HasValue,
|
||||
liveObservations.Count,
|
||||
supersessionResult?.ObservationsSuperseded ?? 0);
|
||||
|
||||
return new PromotionResult(
|
||||
batch.Id,
|
||||
encounterId,
|
||||
liveObservations.Count,
|
||||
batch.SupersedesBatchId.HasValue,
|
||||
supersessionResult);
|
||||
}
|
||||
catch
|
||||
{
|
||||
await transaction.RollbackAsync();
|
||||
throw;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Marks all live observations sourced from the original batch as superseded.
|
||||
/// Records an audit event on the original batch documenting the supersession.
|
||||
/// </summary>
|
||||
private async Task<SupersessionResult> SupersedeOriginalBatchAsync(
|
||||
Guid originalBatchId, Guid correctionBatchId, Guid actorUserId, DateTimeOffset now)
|
||||
{
|
||||
// Load all live observations that came from the original erroneous batch
|
||||
var originalObservations = await _db.LiveObservations
|
||||
.Where(o => o.SourceBatchId == originalBatchId && !o.IsSuperseded)
|
||||
.ToListAsync();
|
||||
|
||||
if (originalObservations.Count == 0)
|
||||
{
|
||||
_logger.LogWarning(
|
||||
"Supersession: no active live observations found for original batch {OriginalBatchId}",
|
||||
originalBatchId);
|
||||
}
|
||||
|
||||
// Mark each observation as superseded — never delete
|
||||
foreach (var obs in originalObservations)
|
||||
{
|
||||
obs.IsSuperseded = true;
|
||||
obs.SupersededByBatchId = correctionBatchId;
|
||||
obs.SupersededAt = now;
|
||||
}
|
||||
|
||||
// Count the correction batch's draft observations for the replacement count
|
||||
var replacementCount = await _db.DigitizationBatches
|
||||
.Where(b => b.Id == correctionBatchId)
|
||||
.SelectMany(b => b.DraftObservations)
|
||||
.CountAsync();
|
||||
|
||||
// Record supersession event on the original batch's audit trail
|
||||
_db.DigitizationEvents.Add(new DigitizationEvent
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
BatchId = originalBatchId,
|
||||
EventType = DigitizationEventType.Superseded,
|
||||
ActorUserId = actorUserId,
|
||||
OccurredAt = now,
|
||||
MetadataJson = JsonSerializer.Serialize(new
|
||||
{
|
||||
supersededByBatchId = correctionBatchId,
|
||||
observationsSuperseded = originalObservations.Count,
|
||||
replacementObservations = replacementCount,
|
||||
reason = "Correction batch promoted — original observations marked superseded"
|
||||
})
|
||||
});
|
||||
|
||||
return new SupersessionResult(
|
||||
originalBatchId,
|
||||
originalObservations.Count,
|
||||
replacementCount,
|
||||
now);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Resolves the live encounter for promotion. For correction batches, reuses the
|
||||
/// encounter from the original batch to maintain continuity. For new batches,
|
||||
/// creates or resolves the encounter from draft data.
|
||||
/// </summary>
|
||||
private async Task<Guid> ResolveEncounterAsync(DigitizationBatch batch)
|
||||
{
|
||||
// For correction batches, reuse the encounter from the original promoted batch
|
||||
if (batch.SupersedesBatchId.HasValue)
|
||||
{
|
||||
var originalBatch = await _db.DigitizationBatches
|
||||
.FirstOrDefaultAsync(b => b.Id == batch.SupersedesBatchId.Value);
|
||||
|
||||
if (originalBatch?.PromotionEncounterId.HasValue == true)
|
||||
{
|
||||
_logger.LogInformation(
|
||||
"Correction batch {CorrectionBatchId} reusing encounter {EncounterId} " +
|
||||
"from original batch {OriginalBatchId}",
|
||||
batch.Id, originalBatch.PromotionEncounterId.Value, originalBatch.Id);
|
||||
|
||||
return originalBatch.PromotionEncounterId.Value;
|
||||
}
|
||||
}
|
||||
|
||||
// For non-correction batches, create or find the encounter from draft data
|
||||
if (batch.DraftEncounter is null)
|
||||
throw new ValidationException(
|
||||
"Batch has no draft encounter data for promotion.",
|
||||
"MISSING_ENCOUNTER_DATA");
|
||||
|
||||
var encounter = new LiveEncounter
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
PatientId = batch.PatientId
|
||||
?? throw new ValidationException("Patient ID is required for promotion.", "MISSING_PATIENT_ID"),
|
||||
AdmissionDate = batch.DraftEncounter.AdmissionDate
|
||||
?? throw new ValidationException("Admission date is required.", "MISSING_ADMISSION_DATE"),
|
||||
Department = batch.DraftEncounter.Department,
|
||||
RoomBed = batch.DraftEncounter.RoomBed,
|
||||
AdmissionReason = batch.DraftEncounter.AdmissionReason,
|
||||
DischargeDiagnosis = batch.DraftEncounter.DischargeDiagnosis,
|
||||
Status = batch.DraftEncounter.Status ?? "active",
|
||||
CreatedAt = DateTimeOffset.UtcNow
|
||||
};
|
||||
|
||||
_db.LiveEncounters.Add(encounter);
|
||||
return encounter.Id;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user