Add AI batch reconciliation for accessibility bills

This commit is contained in:
2026-07-25 18:41:09 +08:00
parent eb8909a192
commit 7cca34b331
25 changed files with 2771 additions and 230 deletions
@@ -61,17 +61,7 @@ public class TransactionsController(AppDbContext db, LedgerResolver ledgers) : C
Amount = req.Amount,
Note = req.Note,
PaymentMethod = req.PaymentMethod,
Source = req.Source switch
{
"voice" => TransactionSource.Voice,
"ocr" => TransactionSource.ReceiptOcr,
"screenshot" => TransactionSource.Screenshot,
"accessibility" => TransactionSource.Accessibility,
"notification" => TransactionSource.Notification,
"recognition_ai" => TransactionSource.RecognitionAi,
"local_ocr" => TransactionSource.LocalOcr,
_ => TransactionSource.Manual,
},
Source = SourceFromWire(req.Source),
SourceText = req.SourceText,
ClientRequestId = clientRequestId,
CreatedAt = DateTime.UtcNow,
@@ -98,6 +88,107 @@ public class TransactionsController(AppDbContext db, LedgerResolver ledgers) : C
}
}
[HttpPost("recognition-batch")]
public async Task<ActionResult<List<RecognitionBatchTransactionDto>>> CreateRecognitionBatch(
CreateRecognitionBatchRequest req,
CancellationToken ct)
{
if (!Guid.TryParse(req.BatchId, out _) || req.Items.Count is < 1 or > 20)
return BadRequest(new ApiError("BATCH_INVALID", "批次标识或账单数量无效"));
if (req.Items.Select(item => item.CandidateId).Distinct().Count() != req.Items.Count ||
req.Items.Select(item => item.ClientRequestId).Distinct().Count() != req.Items.Count)
{
return BadRequest(new ApiError("BATCH_DUPLICATED", "批次中存在重复账单标识"));
}
if (req.Items.Any(item => item.Amount <= 0 ||
item.ClientRequestId.Length is < 1 or > 64 ||
ParseType(item.Type) is null))
{
return BadRequest(new ApiError("BATCH_ITEM_INVALID", "批次中存在无效账单"));
}
var ledgerId = await ledgers.ResolveAsync(Uid, req.LedgerId);
if (!ledgerId.HasValue)
return BadRequest(new ApiError("LEDGER_NOT_FOUND", "账本不存在或无权访问"));
var categoryIds = req.Items.Select(item => item.CategoryId).Distinct().ToList();
var categories = await db.Categories
.Where(category => categoryIds.Contains(category.Id) && !category.IsDeleted &&
(category.UserId == null || category.UserId == Uid))
.ToDictionaryAsync(category => category.Id, ct);
foreach (var item in req.Items)
{
var type = ParseType(item.Type)!.Value;
if (!categories.TryGetValue(item.CategoryId, out var category) || category.Type != type)
return BadRequest(new ApiError("CATEGORY_TYPE_MISMATCH", "分类与收支类型不一致"));
}
var requestIds = req.Items.Select(item => item.ClientRequestId).ToList();
var existing = await db.Transactions
.Include(transaction => transaction.Category)
.Where(transaction => transaction.UserId == Uid &&
transaction.ClientRequestId != null &&
requestIds.Contains(transaction.ClientRequestId))
.ToDictionaryAsync(transaction => transaction.ClientRequestId!, ct);
await using var transactionScope = await db.Database.BeginTransactionAsync(ct);
var mapped = new List<(string CandidateId, Transaction Transaction)>();
foreach (var item in req.Items)
{
if (existing.TryGetValue(item.ClientRequestId, out var found))
{
mapped.Add((item.CandidateId, found));
continue;
}
var category = categories[item.CategoryId];
var transaction = new Transaction
{
LedgerId = ledgerId.Value,
UserId = Uid,
CategoryId = category.Id,
Category = category,
Type = ParseType(item.Type)!.Value,
Amount = item.Amount,
Note = item.Note?.Trim(),
PaymentMethod = item.PaymentMethod?.Trim(),
OccurredAt = NormalizeOccurredAt(item.OccurredAt),
Source = SourceFromWire(item.Source),
SourceText = item.SourceText,
ClientRequestId = item.ClientRequestId,
CreatedAt = DateTime.UtcNow,
UpdatedAt = DateTime.UtcNow,
};
db.Transactions.Add(transaction);
mapped.Add((item.CandidateId, transaction));
}
try
{
await db.SaveChangesAsync(ct);
await transactionScope.CommitAsync(ct);
return Ok(mapped.Select(item => new RecognitionBatchTransactionDto(
item.CandidateId,
ToDto(item.Transaction, item.Transaction.Category))).ToList());
}
catch (DbUpdateException)
{
await transactionScope.RollbackAsync(ct);
foreach (var entry in db.ChangeTracker.Entries<Transaction>()
.Where(entry => entry.State == EntityState.Added))
{
entry.State = EntityState.Detached;
}
var raced = await db.Transactions
.AsNoTracking()
.Include(transaction => transaction.Category)
.Where(transaction => transaction.UserId == Uid &&
transaction.ClientRequestId != null &&
requestIds.Contains(transaction.ClientRequestId))
.ToDictionaryAsync(transaction => transaction.ClientRequestId!, ct);
if (raced.Count != requestIds.Count) throw;
return Ok(req.Items.Select(item => new RecognitionBatchTransactionDto(
item.CandidateId,
ToDto(raced[item.ClientRequestId], raced[item.ClientRequestId].Category))).ToList());
}
}
/// <summary>账单详情(P11:AI 来源追溯、分类、备注、时间)</summary>
[HttpGet("{id:long}")]
public async Task<ActionResult<TransactionDto>> Detail(long id)
@@ -512,6 +603,18 @@ public class TransactionsController(AppDbContext db, LedgerResolver ledgers) : C
: baseUpdatedAt.Value.ToUniversalTime();
return transaction.UpdatedAt > baseline.AddMilliseconds(1);
}
private static TransactionSource SourceFromWire(string? source) => source switch
{
"voice" => TransactionSource.Voice,
"ocr" => TransactionSource.ReceiptOcr,
"screenshot" => TransactionSource.Screenshot,
"accessibility" => TransactionSource.Accessibility,
"notification" => TransactionSource.Notification,
"recognition_ai" => TransactionSource.RecognitionAi,
"local_ocr" => TransactionSource.LocalOcr,
_ => TransactionSource.Manual,
};
private static DateTime NormalizeOccurredAt(DateTime value) =>
value.Kind == DateTimeKind.Utc ? value : ChinaClock.ToUtc(value);