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
@@ -353,7 +353,7 @@ public sealed class ApiIntegrationTests(ApiFixture fixture)
}
[Fact]
public async Task RecognitionClientRequestId_IsIdempotentAcrossConcurrentChannels()
public async Task RecognitionClientRequestId_IsIdempotentAcrossConcurrentChannels()
{
using var client = await fixture.RegisterAsync("recognition_idempotency");
var ledgers = await client.GetFromJsonAsync<JsonElement>("/api/ledgers");
@@ -389,9 +389,89 @@ public sealed class ApiIntegrationTests(ApiFixture fixture)
var month = await client.GetFromJsonAsync<JsonElement>(
$"/api/transactions/month?year=2026&month=7&ledgerId={ledgerId}");
Assert.Equal(1, month.GetProperty("count").GetInt32());
}
}
Assert.Equal(1, month.GetProperty("count").GetInt32());
}
[Fact]
public async Task RecognitionBatch_PreservesThreeConsecutiveTransfers_AndIsIdempotent()
{
using var client = await fixture.RegisterAsync("recognition_batch_three_transfers");
var ledgers = await client.GetFromJsonAsync<JsonElement>("/api/ledgers");
var ledgerId = ledgers[0].GetProperty("id").GetInt64();
var categories = await client.GetFromJsonAsync<JsonElement>(
"/api/categories?type=expense");
var categoryId = categories[0].GetProperty("id").GetInt64();
var batchId = Guid.NewGuid().ToString();
var payload = new
{
batchId,
ledgerId,
items = new[]
{
new
{
candidateId = "transfer-1",
clientRequestId = "recognition-wechat-flow-1",
categoryId,
type = "expense",
amount = 20m,
note = "转账给张三",
paymentMethod = "微信",
occurredAt = "2026-07-25T10:00:01+08:00",
source = "recognition_ai",
sourceText = "微信转账成功",
},
new
{
candidateId = "transfer-2",
clientRequestId = "recognition-wechat-flow-2",
categoryId,
type = "expense",
amount = 20m,
note = "转账给张三",
paymentMethod = "微信",
occurredAt = "2026-07-25T10:00:10+08:00",
source = "recognition_ai",
sourceText = "微信转账成功",
},
new
{
candidateId = "transfer-3",
clientRequestId = "recognition-wechat-flow-3",
categoryId,
type = "expense",
amount = 30m,
note = "转账给张三",
paymentMethod = "微信",
occurredAt = "2026-07-25T10:00:20+08:00",
source = "recognition_ai",
sourceText = "微信转账成功",
},
},
};
var responses = await Task.WhenAll(
client.PostAsJsonAsync("/api/transactions/recognition-batch", payload),
client.PostAsJsonAsync("/api/transactions/recognition-batch", payload));
Assert.All(responses, response => response.EnsureSuccessStatusCode());
var results = await Task.WhenAll(
responses.Select(response => response.Content.ReadFromJsonAsync<JsonElement>()));
var first = results[0];
var retry = results[1];
Assert.Equal(3, first.GetArrayLength());
Assert.Equal(3, retry.GetArrayLength());
Assert.Equal(
first.EnumerateArray()
.Select(item => item.GetProperty("transaction").GetProperty("id").GetInt64()),
retry.EnumerateArray()
.Select(item => item.GetProperty("transaction").GetProperty("id").GetInt64()));
var month = await client.GetFromJsonAsync<JsonElement>(
$"/api/transactions/month?year=2026&month=7&ledgerId={ledgerId}");
Assert.Equal(3, month.GetProperty("count").GetInt32());
Assert.Equal(70m, month.GetProperty("expense").GetDecimal());
}
}
public sealed class ChinaClockTests
{
@@ -427,7 +507,7 @@ public sealed class ChinaClockTests
yearEnd);
}
}
public sealed class ImageParseResultTests
public sealed class ImageParseResultTests
{
private static readonly MethodInfo ParseMethod =
typeof(OpenAiVisionClient).GetMethod(
@@ -479,7 +559,52 @@ public sealed class ImageParseResultTests
results[1].OccurredAt);
Assert.Null(results[2].OccurredAt);
}
}
}
public sealed class RecognitionBatchActionParserTests
{
private static readonly MethodInfo ParseMethod =
typeof(OpenAiVisionClient).GetMethod(
"ParseRecognitionBatchActions",
BindingFlags.NonPublic | BindingFlags.Static)
?? throw new InvalidOperationException("Recognition batch parser not found");
[Fact]
public void Actions_AreParsedFromFencedJson_AndInvalidActionsAreIgnored()
{
const string payload =
"""
result:
```json
{
"actions": [
{
"action": "update",
"actionId": "a1",
"candidateId": "candidate-1",
"amount": 20,
"confidence": 1.4,
"reason": "修正金额"
},
{
"action": "merge",
"candidateId": "candidate-2"
}
]
}
```
""";
var actions = Assert.IsAssignableFrom<IReadOnlyList<RecognitionBatchModelAction>>(
ParseMethod.Invoke(null, [payload]));
var action = Assert.Single(actions);
Assert.Equal("update", action.Action);
Assert.Equal("candidate-1", action.CandidateId);
Assert.Equal(20m, action.Amount);
Assert.Equal(1, action.Confidence);
}
}
public sealed class BudgetRecommendationValidationTests
{