feature: Corrections and Supersession

This commit is contained in:
voltsrage
2026-06-26 16:33:31 +08:00
parent 706318e5d2
commit f232761fd7
35 changed files with 4907 additions and 75 deletions
@@ -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));
}
}
+2
View File
@@ -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");
}
}
File diff suppressed because it is too large Load Diff
@@ -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");
}
}
}
@@ -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
);
+3 -1
View File
@@ -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);
+61 -8
View File
@@ -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
);
}
}
+8 -5
View File
@@ -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);
}
+363 -19
View File
@@ -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;
}
}