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
@@ -951,6 +951,37 @@ class LocalDatabase {
return transaction(id, includeDeleted: true)!;
}
List<Map<String, dynamic>> createTransactionsBatch(
List<Map<String, dynamic>> values, {
bool enqueueSyncChanges = false,
}) {
_db.execute('BEGIN');
try {
final created = <Map<String, dynamic>>[];
for (final value in values) {
final clientRequestId = value['clientRequestId'] as String?;
final existed =
clientRequestId != null &&
clientRequestId.isNotEmpty &&
_db.select(
'SELECT 1 FROM transactions WHERE client_request_id = ? LIMIT 1',
[clientRequestId],
).isNotEmpty;
final transaction = createTransaction(value);
created.add(transaction);
final id = (transaction['id'] as num).toInt();
if (enqueueSyncChanges && id < 0 && !existed) {
enqueueSync('transaction', id, 'create', value);
}
}
_db.execute('COMMIT');
return created;
} catch (_) {
_db.execute('ROLLBACK');
rethrow;
}
}
Map<String, dynamic>? transaction(int id, {bool includeDeleted = false}) {
final rows = _db.select(
'''
@@ -1,4 +1,5 @@
import 'package:flutter/material.dart';
import 'package:go_router/go_router.dart';
import 'package:miaoji_zhang/features/home/pages/transaction_edit_page.dart';
import 'package:miaoji_zhang/shared/api/api_client.dart';
import 'package:miaoji_zhang/shared/api/business_api.dart';
@@ -10,14 +11,20 @@ import 'package:miaoji_zhang/shared/services/shanghai_time.dart';
import 'package:miaoji_zhang/shared/services/transaction_events.dart';
import 'package:miaoji_zhang/shared/theme/app_theme.dart';
import 'package:miaoji_zhang/shared/widgets/app_controls.dart';
import 'package:shared_preferences/shared_preferences.dart';
class RecognitionImportService {
RecognitionImportService._();
static bool _processing = false;
static Future<void>? _activeImport;
static bool _rerunRequested = false;
static Future<void> configureNativeContext() async {
final session = SessionStore.instance;
final preferences = await SharedPreferences.getInstance();
if (preferences.getBool('ai_batch_consent_v2') != true) {
await ScreenshotChannel.setRecognitionToggle('ai_screenshot', false);
}
await ScreenshotChannel.configureRecognitionContext(
hasAccount: session.isAccount,
aiAllowed: session.aiEnabled,
@@ -26,17 +33,46 @@ class RecognitionImportService {
);
}
static Future<void> importAutomatic() async {
if (_processing || !SessionStore.instance.hasSession) return;
_processing = true;
static Future<void> importAutomatic() {
if (!SessionStore.instance.hasSession) return Future.value();
final active = _activeImport;
if (active != null) {
_rerunRequested = true;
return active;
}
final operation = _runAutomaticImports();
_activeImport = operation;
return operation.whenComplete(() {
if (identical(_activeImport, operation)) _activeImport = null;
});
}
static Future<void> _runAutomaticImports() async {
try {
await CurrentLedgerStore.instance.ensureLoaded();
final candidates = await ScreenshotChannel.drainRecognitionCandidates();
for (final candidate in candidates.where((item) => item.canAutoImport)) {
await _import(candidate);
}
do {
_rerunRequested = false;
await CurrentLedgerStore.instance.ensureLoaded();
final candidates = await ScreenshotChannel.drainRecognitionCandidates();
final automatic = candidates
.where((item) => item.canAutoImport)
.toList();
final batches = <String, List<RecognitionCandidate>>{};
for (final candidate in automatic) {
final batchId = candidate.batchId;
if (batchId == null || batchId.isEmpty) continue;
batches.putIfAbsent(batchId, () => []).add(candidate);
}
for (final entry in batches.entries) {
await _importBatch(entry.key, entry.value);
}
for (final candidate in automatic.where(
(item) => item.batchId == null || item.batchId!.isEmpty,
)) {
await _import(candidate);
}
} while (_rerunRequested);
} finally {
_processing = false;
_rerunRequested = false;
}
}
@@ -49,6 +85,20 @@ class RecognitionImportService {
await importAutomatic();
return;
}
if (kind == 'recognition_batch_review') {
await importAutomatic();
if (!context.mounted) return;
final batchId = action['batchId']?.toString();
context.push(
Uri(
path: '/recognition-batches',
queryParameters: batchId == null || batchId.isEmpty
? null
: {'batchId': batchId},
).toString(),
);
return;
}
if (kind == 'recognition_undo') {
final transactionId = (action['transactionId'] as num?)?.toInt();
final candidateId = action['candidateId']?.toString();
@@ -130,17 +180,7 @@ class RecognitionImportService {
}
static Future<TxItem> _import(RecognitionCandidate candidate) async {
if (candidate.type != 'income' && candidate.type != 'expense') {
throw StateError('识别结果缺少明确的收支类型');
}
final categories = await TxApi.categories(candidate.type);
if (categories.isEmpty) throw StateError('当前账本没有可用分类');
final category =
categories
.where((item) => item.name == candidate.categoryHint)
.firstOrNull ??
categories.where((item) => item.name == '其他').firstOrNull ??
categories.first;
final category = await _resolveCategory(candidate, {});
final occurredUtc = validOccurredAtUtc(candidate.occurredAtEpochMs);
final transaction = await TxApi.create(
categoryId: category.id,
@@ -164,6 +204,73 @@ class RecognitionImportService {
return transaction;
}
static Future<void> _importBatch(
String batchId,
List<RecognitionCandidate> candidates,
) async {
final categoryCache = <String, List<CategoryItem>>{};
final drafts = <RecognitionBatchDraft>[];
for (final candidate in candidates) {
final category = await _resolveCategory(candidate, categoryCache);
final occurredUtc = validOccurredAtUtc(candidate.occurredAtEpochMs);
drafts.add(
RecognitionBatchDraft(
candidateId: candidate.id,
clientRequestId: candidate.clientRequestId,
categoryId: category.id,
type: candidate.type,
amount: candidate.amount,
note: candidate.merchant?.trim().isNotEmpty == true
? candidate.merchant!.trim()
: (candidate.note ?? '智能识别'),
paymentMethod: candidate.appName,
source: candidate.source,
sourceText: candidate.sourceText,
occurredAt: ShanghaiTime.toCivil(occurredUtc),
),
);
}
final transactions = await TxApi.createRecognitionBatch(batchId, drafts);
if (transactions.length != drafts.length) {
throw StateError('批次入账结果不完整,请稍后重试');
}
for (final candidate in candidates) {
final transaction = transactions[candidate.id];
if (transaction == null) {
throw StateError('批次入账缺少候选 ${candidate.id}');
}
final acknowledged =
await ScreenshotChannel.acknowledgeRecognitionCandidate(
candidate.id,
'imported',
transactionId: transaction.id,
);
if (!acknowledged) throw StateError('批次状态确认失败,请稍后重试');
}
TransactionEvents.notifyChanged();
}
static Future<CategoryItem> _resolveCategory(
RecognitionCandidate candidate,
Map<String, List<CategoryItem>> cache,
) async {
if (candidate.type != 'income' && candidate.type != 'expense') {
throw StateError('识别结果缺少明确的收支类型');
}
final categories = cache[candidate.type] ??= await TxApi.categories(
candidate.type,
);
if (categories.isEmpty) throw StateError('当前账本没有可用分类');
return categories
.where((item) => item.id == candidate.categoryId)
.firstOrNull ??
categories
.where((item) => item.name == candidate.categoryHint)
.firstOrNull ??
categories.where((item) => item.name == '其他').firstOrNull ??
categories.first;
}
static DateTime validOccurredAtUtc(int epochMs, {DateTime? now}) {
final current = (now ?? DateTime.now()).toUtc();
final parsed = DateTime.fromMillisecondsSinceEpoch(epochMs, isUtc: true);
@@ -138,6 +138,8 @@ class RecognitionCandidate {
final String? merchant, orderId, sourceText, note;
final String recognitionKind, amountSource;
final String? categoryHint, resultFingerprint;
final String? batchId, aiAction, aiReason;
final int? categoryId;
final int occurredAtEpochMs;
RecognitionCandidate.fromJson(Map<String, dynamic> value)
@@ -155,13 +157,68 @@ class RecognitionCandidate {
note = value['note'] as String?,
recognitionKind = value['recognitionKind']?.toString() ?? 'payment',
categoryHint = value['categoryHint']?.toString(),
categoryId = (value['categoryId'] as num?)?.toInt(),
amountSource = value['amountSource']?.toString() ?? 'result',
resultFingerprint = value['resultFingerprint']?.toString(),
batchId = value['batchId']?.toString(),
aiAction = value['aiAction']?.toString(),
aiReason = value['aiReason']?.toString(),
occurredAtEpochMs = (value['occurredAtEpochMs'] as num).toInt();
bool get canAutoImport => state == 'auto_ready' && confidence == 'auto';
}
class RecognitionBatchItem {
final String candidateId, action, reason, state, type;
final double amount;
final String? merchant;
final bool canRestore;
RecognitionBatchItem.fromJson(Map<String, dynamic> value)
: candidateId = value['candidateId']?.toString() ?? '',
action = value['action']?.toString() ?? 'keep',
reason = value['reason']?.toString() ?? '',
state = value['state']?.toString() ?? '',
type = value['type']?.toString() ?? 'expense',
amount = (value['amount'] as num?)?.toDouble() ?? 0,
merchant = value['merchant']?.toString(),
canRestore = value['canRestore'] as bool? ?? false;
}
class RecognitionBatch {
final String id, state;
final DateTime openedAt, completedAt;
final int kept, updated, created, dropped;
final bool fallback;
final String? failureReason;
final List<RecognitionBatchItem> items;
RecognitionBatch.fromJson(Map<String, dynamic> value)
: id = value['id']?.toString() ?? '',
state = value['state']?.toString() ?? '',
openedAt = DateTime.fromMillisecondsSinceEpoch(
(value['openedAt'] as num?)?.toInt() ?? 0,
isUtc: true,
),
completedAt = DateTime.fromMillisecondsSinceEpoch(
(value['completedAt'] as num?)?.toInt() ?? 0,
isUtc: true,
),
kept = ((value['summary'] as Map?)?['kept'] as num?)?.toInt() ?? 0,
updated = ((value['summary'] as Map?)?['updated'] as num?)?.toInt() ?? 0,
created = ((value['summary'] as Map?)?['created'] as num?)?.toInt() ?? 0,
dropped = ((value['summary'] as Map?)?['dropped'] as num?)?.toInt() ?? 0,
fallback = ((value['summary'] as Map?)?['fallback'] as bool?) ?? false,
failureReason = value['failureReason']?.toString(),
items = (value['items'] as List<dynamic>? ?? const [])
.map(
(item) => RecognitionBatchItem.fromJson(
Map<String, dynamic>.from(item as Map),
),
)
.toList(growable: false);
}
class SpeechEvent {
final String type;
final String? text;
@@ -398,6 +455,37 @@ class ScreenshotChannel {
}
}
static Future<List<RecognitionBatch>> listRecognitionBatches() async {
try {
final values =
await _channel.invokeMethod<List<Object?>>(
'listRecognitionBatches',
) ??
const [];
return values
.whereType<String>()
.map(
(value) => RecognitionBatch.fromJson(
jsonDecode(value) as Map<String, dynamic>,
),
)
.toList(growable: false);
} on MissingPluginException {
return const [];
}
}
static Future<bool> restoreDroppedRecognition(String candidateId) async {
try {
return await _channel.invokeMethod<bool>('restoreDroppedRecognition', {
'candidateId': candidateId,
}) ??
false;
} on MissingPluginException {
return false;
}
}
static Future<bool> requestNotificationPermission() async {
try {
return await _channel.invokeMethod<bool>(