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));
}
}