Add AI batch reconciliation for accessibility bills
This commit is contained in:
@@ -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
|
||||
{
|
||||
|
||||
Reference in New Issue
Block a user