5eb27319e1
Add ReceiptQueue page with tabbed dashboard (queued/completed/failed), AJAX polling for live status updates, and per-receipt retry. Update Receipts page with multi-file upload modal, file preview, upload spinner, and bulk retry for failed parses. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
221 lines
8.2 KiB
C#
221 lines
8.2 KiB
C#
using Microsoft.AspNetCore.Mvc;
|
|
using Microsoft.AspNetCore.Mvc.RazorPages;
|
|
using Microsoft.EntityFrameworkCore;
|
|
using MoneyMap.Data;
|
|
using MoneyMap.Models;
|
|
using MoneyMap.Services;
|
|
|
|
namespace MoneyMap.Pages
|
|
{
|
|
public class ReceiptQueueModel : PageModel
|
|
{
|
|
private readonly MoneyMapContext _db;
|
|
private readonly IReceiptManager _receiptManager;
|
|
private readonly IReceiptParseQueue _parseQueue;
|
|
|
|
public ReceiptQueueModel(
|
|
MoneyMapContext db,
|
|
IReceiptManager receiptManager,
|
|
IReceiptParseQueue parseQueue)
|
|
{
|
|
_db = db;
|
|
_receiptManager = receiptManager;
|
|
_parseQueue = parseQueue;
|
|
}
|
|
|
|
public List<QueueItemViewModel> QueuedItems { get; set; } = new();
|
|
public List<QueueItemViewModel> CompletedItems { get; set; } = new();
|
|
public List<QueueItemViewModel> FailedItems { get; set; } = new();
|
|
public QueueItemViewModel? CurrentlyProcessing { get; set; }
|
|
|
|
[TempData]
|
|
public string? Message { get; set; }
|
|
|
|
[TempData]
|
|
public bool IsSuccess { get; set; }
|
|
|
|
public async Task OnGetAsync()
|
|
{
|
|
await LoadQueueDashboardAsync();
|
|
}
|
|
|
|
public async Task<IActionResult> OnPostUploadAsync(List<IFormFile> files)
|
|
{
|
|
if (files == null || files.Count == 0)
|
|
{
|
|
Message = "Please select files to upload.";
|
|
IsSuccess = false;
|
|
return RedirectToPage();
|
|
}
|
|
|
|
var result = await _receiptManager.UploadManyUnmappedReceiptsAsync(files);
|
|
|
|
var messages = new List<string>();
|
|
if (result.Uploaded.Count > 0)
|
|
messages.Add($"{result.Uploaded.Count} receipt(s) uploaded and queued for parsing.");
|
|
if (result.Failed.Count > 0)
|
|
messages.Add($"{result.Failed.Count} failed: " +
|
|
string.Join("; ", result.Failed.Select(f => $"{f.FileName}: {f.ErrorMessage}")));
|
|
|
|
Message = string.Join(" ", messages);
|
|
IsSuccess = result.Failed.Count == 0;
|
|
|
|
return RedirectToPage();
|
|
}
|
|
|
|
public async Task<IActionResult> OnGetQueueStatusAsync()
|
|
{
|
|
await LoadQueueDashboardAsync();
|
|
|
|
var data = new
|
|
{
|
|
currentlyProcessing = CurrentlyProcessing,
|
|
queued = QueuedItems,
|
|
completed = CompletedItems,
|
|
failed = FailedItems
|
|
};
|
|
|
|
return new JsonResult(data);
|
|
}
|
|
|
|
public async Task<IActionResult> OnPostRetryAsync(long receiptId)
|
|
{
|
|
var receipt = await _db.Receipts.FindAsync(receiptId);
|
|
if (receipt == null)
|
|
{
|
|
Message = "Receipt not found.";
|
|
IsSuccess = false;
|
|
return RedirectToPage();
|
|
}
|
|
|
|
receipt.ParseStatus = ReceiptParseStatus.Queued;
|
|
await _db.SaveChangesAsync();
|
|
await _parseQueue.EnqueueAsync(receiptId);
|
|
|
|
Message = $"Receipt \"{receipt.FileName}\" re-queued for parsing.";
|
|
IsSuccess = true;
|
|
return RedirectToPage();
|
|
}
|
|
|
|
private async Task LoadQueueDashboardAsync()
|
|
{
|
|
var currentId = _parseQueue.CurrentlyProcessingId;
|
|
|
|
// Load all non-NotRequested receipts (recent first, limit to keep things manageable)
|
|
var recentReceipts = await _db.Receipts
|
|
.Where(r => r.ParseStatus != ReceiptParseStatus.NotRequested)
|
|
.OrderByDescending(r => r.UploadedAtUtc)
|
|
.Take(200)
|
|
.Select(r => new QueueItemViewModel
|
|
{
|
|
ReceiptId = r.Id,
|
|
FileName = r.FileName,
|
|
UploadedAtUtc = r.UploadedAtUtc,
|
|
ParseStatus = r.ParseStatus,
|
|
Merchant = r.Merchant,
|
|
Total = r.Total,
|
|
LineItemCount = r.LineItems.Count
|
|
})
|
|
.ToListAsync();
|
|
|
|
// Get error messages from latest parse log for failed items
|
|
var failedIds = recentReceipts
|
|
.Where(r => r.ParseStatus == ReceiptParseStatus.Failed)
|
|
.Select(r => r.ReceiptId)
|
|
.ToList();
|
|
|
|
if (failedIds.Count > 0)
|
|
{
|
|
var errorLogs = await _db.ReceiptParseLogs
|
|
.Where(l => failedIds.Contains(l.ReceiptId) && !l.Success)
|
|
.GroupBy(l => l.ReceiptId)
|
|
.Select(g => new { ReceiptId = g.Key, Error = g.OrderByDescending(l => l.StartedAtUtc).First().Error })
|
|
.ToListAsync();
|
|
|
|
var errorMap = errorLogs.ToDictionary(e => e.ReceiptId, e => e.Error);
|
|
|
|
foreach (var item in recentReceipts.Where(r => r.ParseStatus == ReceiptParseStatus.Failed))
|
|
{
|
|
item.ErrorMessage = errorMap.GetValueOrDefault(item.ReceiptId);
|
|
}
|
|
}
|
|
|
|
// Get confidence from latest successful parse log
|
|
var completedIds = recentReceipts
|
|
.Where(r => r.ParseStatus == ReceiptParseStatus.Completed)
|
|
.Select(r => r.ReceiptId)
|
|
.ToList();
|
|
|
|
if (completedIds.Count > 0)
|
|
{
|
|
var confidenceLogs = await _db.ReceiptParseLogs
|
|
.Where(l => completedIds.Contains(l.ReceiptId) && l.Success)
|
|
.GroupBy(l => l.ReceiptId)
|
|
.Select(g => new { ReceiptId = g.Key, Confidence = g.OrderByDescending(l => l.StartedAtUtc).First().Confidence })
|
|
.ToListAsync();
|
|
|
|
var confidenceMap = confidenceLogs.ToDictionary(c => c.ReceiptId, c => c.Confidence);
|
|
|
|
foreach (var item in recentReceipts.Where(r => r.ParseStatus == ReceiptParseStatus.Completed))
|
|
{
|
|
item.Confidence = confidenceMap.GetValueOrDefault(item.ReceiptId);
|
|
}
|
|
}
|
|
|
|
// Assign queue positions for queued items
|
|
var queuedList = recentReceipts
|
|
.Where(r => r.ParseStatus == ReceiptParseStatus.Queued)
|
|
.OrderBy(r => r.UploadedAtUtc)
|
|
.ToList();
|
|
|
|
for (int i = 0; i < queuedList.Count; i++)
|
|
queuedList[i].QueuePosition = i + 1;
|
|
|
|
QueuedItems = queuedList;
|
|
CompletedItems = recentReceipts
|
|
.Where(r => r.ParseStatus == ReceiptParseStatus.Completed)
|
|
.OrderByDescending(r => r.UploadedAtUtc)
|
|
.ToList();
|
|
FailedItems = recentReceipts
|
|
.Where(r => r.ParseStatus == ReceiptParseStatus.Failed)
|
|
.OrderByDescending(r => r.UploadedAtUtc)
|
|
.ToList();
|
|
|
|
if (currentId.HasValue)
|
|
{
|
|
CurrentlyProcessing = recentReceipts
|
|
.FirstOrDefault(r => r.ReceiptId == currentId.Value);
|
|
|
|
// If currently processing item isn't in our recent list, load it
|
|
if (CurrentlyProcessing == null)
|
|
{
|
|
CurrentlyProcessing = await _db.Receipts
|
|
.Where(r => r.Id == currentId.Value)
|
|
.Select(r => new QueueItemViewModel
|
|
{
|
|
ReceiptId = r.Id,
|
|
FileName = r.FileName,
|
|
UploadedAtUtc = r.UploadedAtUtc,
|
|
ParseStatus = r.ParseStatus
|
|
})
|
|
.FirstOrDefaultAsync();
|
|
}
|
|
}
|
|
}
|
|
|
|
public class QueueItemViewModel
|
|
{
|
|
public long ReceiptId { get; set; }
|
|
public string FileName { get; set; } = "";
|
|
public DateTime UploadedAtUtc { get; set; }
|
|
public ReceiptParseStatus ParseStatus { get; set; }
|
|
public int QueuePosition { get; set; }
|
|
public string? Merchant { get; set; }
|
|
public decimal? Total { get; set; }
|
|
public decimal? Confidence { get; set; }
|
|
public string? ErrorMessage { get; set; }
|
|
public int LineItemCount { get; set; }
|
|
}
|
|
}
|
|
}
|