Add AI batch reconciliation for accessibility bills
This commit is contained in:
@@ -185,6 +185,27 @@ class PeriodStats {
|
||||
analysis = json['analysis'] as String?;
|
||||
}
|
||||
|
||||
class RecognitionBatchDraft {
|
||||
final String candidateId, clientRequestId, type, source;
|
||||
final int categoryId;
|
||||
final double amount;
|
||||
final DateTime occurredAt;
|
||||
final String? note, paymentMethod, sourceText;
|
||||
|
||||
const RecognitionBatchDraft({
|
||||
required this.candidateId,
|
||||
required this.clientRequestId,
|
||||
required this.categoryId,
|
||||
required this.type,
|
||||
required this.amount,
|
||||
required this.occurredAt,
|
||||
required this.source,
|
||||
this.note,
|
||||
this.paymentMethod,
|
||||
this.sourceText,
|
||||
});
|
||||
}
|
||||
|
||||
class TxApi {
|
||||
static final _dio = ApiClient.instance.dio;
|
||||
|
||||
@@ -361,6 +382,86 @@ class TxApi {
|
||||
}
|
||||
}
|
||||
|
||||
static Future<Map<String, TxItem>> createRecognitionBatch(
|
||||
String batchId,
|
||||
List<RecognitionBatchDraft> drafts,
|
||||
) async {
|
||||
if (drafts.isEmpty) return const {};
|
||||
final localPayloads = drafts
|
||||
.map(
|
||||
(draft) => <String, dynamic>{
|
||||
'ledgerId': _ledgerId,
|
||||
'categoryId': draft.categoryId,
|
||||
'type': draft.type,
|
||||
'amount': draft.amount,
|
||||
'note': draft.note,
|
||||
'paymentMethod': draft.paymentMethod,
|
||||
'source': draft.source,
|
||||
'sourceText': draft.sourceText,
|
||||
'occurredAt': ShanghaiTime.civilToUtc(
|
||||
draft.occurredAt,
|
||||
).toIso8601String(),
|
||||
'clientRequestId': draft.clientRequestId,
|
||||
},
|
||||
)
|
||||
.toList(growable: false);
|
||||
|
||||
Map<String, TxItem> createLocal() {
|
||||
final values = LocalDatabase.instance.createTransactionsBatch(
|
||||
localPayloads,
|
||||
enqueueSyncChanges: _queueOfflineChanges,
|
||||
);
|
||||
return {
|
||||
for (var index = 0; index < drafts.length; index++)
|
||||
drafts[index].candidateId: TxItem.fromJson(values[index]),
|
||||
};
|
||||
}
|
||||
|
||||
final session = SessionStore.instance;
|
||||
if (session.shouldUseLocalOnly ||
|
||||
_ledgerId < 0 ||
|
||||
drafts.any((draft) => draft.categoryId < 0)) {
|
||||
return createLocal();
|
||||
}
|
||||
try {
|
||||
final response = await _dio.post(
|
||||
'/api/transactions/recognition-batch',
|
||||
data: {
|
||||
'batchId': batchId,
|
||||
'ledgerId': _ledgerId,
|
||||
'items': [
|
||||
for (var index = 0; index < drafts.length; index++)
|
||||
{
|
||||
'candidateId': drafts[index].candidateId,
|
||||
'clientRequestId': drafts[index].clientRequestId,
|
||||
'categoryId': drafts[index].categoryId,
|
||||
'type': drafts[index].type,
|
||||
'amount': drafts[index].amount,
|
||||
'note': drafts[index].note,
|
||||
'paymentMethod': drafts[index].paymentMethod,
|
||||
'occurredAt': localPayloads[index]['occurredAt'],
|
||||
'source': drafts[index].source,
|
||||
'sourceText': drafts[index].sourceText,
|
||||
},
|
||||
],
|
||||
},
|
||||
);
|
||||
final result = <String, TxItem>{};
|
||||
for (final raw in response.data as List<dynamic>) {
|
||||
final item = Map<String, dynamic>.from(raw as Map);
|
||||
final transaction = Map<String, dynamic>.from(
|
||||
item['transaction'] as Map,
|
||||
);
|
||||
LocalDatabase.instance.cacheTransaction(transaction);
|
||||
result[item['candidateId'].toString()] = TxItem.fromJson(transaction);
|
||||
}
|
||||
return result;
|
||||
} catch (error) {
|
||||
if (!isConnectivityError(error) || !session.isAccount) rethrow;
|
||||
return createLocal();
|
||||
}
|
||||
}
|
||||
|
||||
static Future<void> delete(int id) async {
|
||||
final baseUpdatedAt = LocalDatabase.instance.transaction(
|
||||
id,
|
||||
|
||||
@@ -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>(
|
||||
|
||||
Reference in New Issue
Block a user