Add transfer tracking and secure admin access
This commit is contained in:
@@ -15,6 +15,9 @@ class TxItem {
|
||||
final String categoryName, categoryIcon, categoryColor, type, source;
|
||||
final double amount;
|
||||
final String? note, paymentMethod, sourceText;
|
||||
final String? transferDirection, counterparty;
|
||||
final String? provider, providerTransactionId, recognitionOccurrenceId;
|
||||
final String? evidenceFingerprint, recognitionConfidence;
|
||||
final DateTime occurredAt, updatedAt;
|
||||
final bool isDeleted;
|
||||
|
||||
@@ -31,6 +34,13 @@ class TxItem {
|
||||
paymentMethod = j['paymentMethod'] as String?,
|
||||
source = j['source'] as String,
|
||||
sourceText = j['sourceText'] as String?,
|
||||
transferDirection = j['transferDirection'] as String?,
|
||||
counterparty = j['counterparty'] as String?,
|
||||
provider = j['provider'] as String?,
|
||||
providerTransactionId = j['providerTransactionId'] as String?,
|
||||
recognitionOccurrenceId = j['recognitionOccurrenceId'] as String?,
|
||||
evidenceFingerprint = j['evidenceFingerprint'] as String?,
|
||||
recognitionConfidence = j['recognitionConfidence'] as String?,
|
||||
occurredAt = ShanghaiTime.parseCivil(j['occurredAt'] as String),
|
||||
updatedAt = DateTime.parse(
|
||||
j['updatedAt'] as String? ?? j['occurredAt'] as String,
|
||||
@@ -48,7 +58,18 @@ class TxItem {
|
||||
source == 'accessibility' ||
|
||||
source == 'notification' ||
|
||||
source == 'local_ocr';
|
||||
bool get isIncome => type == 'income';
|
||||
bool get isTransfer => type == 'transfer';
|
||||
bool get isIncome =>
|
||||
type == 'income' || isTransfer && transferDirection == 'in';
|
||||
bool get isExpense =>
|
||||
type == 'expense' || isTransfer && transferDirection == 'out';
|
||||
String get typeLabel => isTransfer
|
||||
? transferDirection == 'in'
|
||||
? '转入'
|
||||
: '转出'
|
||||
: isIncome
|
||||
? '收入'
|
||||
: '支出';
|
||||
|
||||
String get sourceLabel => switch (source) {
|
||||
'ai_chat' => 'AI 聊天',
|
||||
@@ -191,6 +212,9 @@ class RecognitionBatchDraft {
|
||||
final double amount;
|
||||
final DateTime occurredAt;
|
||||
final String? note, paymentMethod, sourceText;
|
||||
final String? transferDirection, counterparty;
|
||||
final String? provider, providerTransactionId, recognitionOccurrenceId;
|
||||
final String? evidenceFingerprint, recognitionConfidence;
|
||||
|
||||
const RecognitionBatchDraft({
|
||||
required this.candidateId,
|
||||
@@ -203,6 +227,13 @@ class RecognitionBatchDraft {
|
||||
this.note,
|
||||
this.paymentMethod,
|
||||
this.sourceText,
|
||||
this.transferDirection,
|
||||
this.counterparty,
|
||||
this.provider,
|
||||
this.providerTransactionId,
|
||||
this.recognitionOccurrenceId,
|
||||
this.evidenceFingerprint,
|
||||
this.recognitionConfidence,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -336,6 +367,13 @@ class TxApi {
|
||||
String? sourceText,
|
||||
DateTime? occurredAt,
|
||||
String? clientRequestId,
|
||||
String? transferDirection,
|
||||
String? counterparty,
|
||||
String? provider,
|
||||
String? providerTransactionId,
|
||||
String? recognitionOccurrenceId,
|
||||
String? evidenceFingerprint,
|
||||
String? recognitionConfidence,
|
||||
}) async {
|
||||
final payload = <String, dynamic>{
|
||||
'ledgerId': _ledgerId,
|
||||
@@ -350,6 +388,17 @@ class TxApi {
|
||||
occurredAt ?? ShanghaiTime.now,
|
||||
).toIso8601String(),
|
||||
if (clientRequestId != null) 'clientRequestId': clientRequestId,
|
||||
if (transferDirection != null) 'transferDirection': transferDirection,
|
||||
if (counterparty != null) 'counterparty': counterparty,
|
||||
if (provider != null) 'provider': provider,
|
||||
if (providerTransactionId != null)
|
||||
'providerTransactionId': providerTransactionId,
|
||||
if (recognitionOccurrenceId != null)
|
||||
'recognitionOccurrenceId': recognitionOccurrenceId,
|
||||
if (evidenceFingerprint != null)
|
||||
'evidenceFingerprint': evidenceFingerprint,
|
||||
if (recognitionConfidence != null)
|
||||
'recognitionConfidence': recognitionConfidence,
|
||||
};
|
||||
final session = SessionStore.instance;
|
||||
if (session.shouldUseLocalOnly || categoryId < 0 || _ledgerId < 0) {
|
||||
@@ -402,6 +451,13 @@ class TxApi {
|
||||
draft.occurredAt,
|
||||
).toIso8601String(),
|
||||
'clientRequestId': draft.clientRequestId,
|
||||
'transferDirection': draft.transferDirection,
|
||||
'counterparty': draft.counterparty,
|
||||
'provider': draft.provider,
|
||||
'providerTransactionId': draft.providerTransactionId,
|
||||
'recognitionOccurrenceId': draft.recognitionOccurrenceId,
|
||||
'evidenceFingerprint': draft.evidenceFingerprint,
|
||||
'recognitionConfidence': draft.recognitionConfidence,
|
||||
},
|
||||
)
|
||||
.toList(growable: false);
|
||||
@@ -442,6 +498,14 @@ class TxApi {
|
||||
'occurredAt': localPayloads[index]['occurredAt'],
|
||||
'source': drafts[index].source,
|
||||
'sourceText': drafts[index].sourceText,
|
||||
'transferDirection': drafts[index].transferDirection,
|
||||
'counterparty': drafts[index].counterparty,
|
||||
'provider': drafts[index].provider,
|
||||
'providerTransactionId': drafts[index].providerTransactionId,
|
||||
'recognitionOccurrenceId':
|
||||
drafts[index].recognitionOccurrenceId,
|
||||
'evidenceFingerprint': drafts[index].evidenceFingerprint,
|
||||
'recognitionConfidence': drafts[index].recognitionConfidence,
|
||||
},
|
||||
],
|
||||
},
|
||||
@@ -499,6 +563,8 @@ class TxApi {
|
||||
required DateTime occurredAt,
|
||||
String? note,
|
||||
String? paymentMethod,
|
||||
String? transferDirection,
|
||||
String? counterparty,
|
||||
}) async {
|
||||
final baseUpdatedAt = LocalDatabase.instance.transaction(
|
||||
id,
|
||||
@@ -511,6 +577,8 @@ class TxApi {
|
||||
'amount': amount,
|
||||
'note': note,
|
||||
'paymentMethod': paymentMethod,
|
||||
'transferDirection': transferDirection,
|
||||
'counterparty': counterparty,
|
||||
'occurredAt': ShanghaiTime.civilToUtc(occurredAt).toIso8601String(),
|
||||
if (baseUpdatedAt != null) 'baseUpdatedAt': baseUpdatedAt,
|
||||
};
|
||||
@@ -1447,7 +1515,7 @@ class ParsedDraft {
|
||||
final int categoryId;
|
||||
final String categoryName, categoryIcon, note, type;
|
||||
final double amount;
|
||||
final String? paymentMethod;
|
||||
final String? paymentMethod, transferDirection, counterparty;
|
||||
|
||||
ParsedDraft.fromJson(Map<String, dynamic> j)
|
||||
: matched = j['matched'] as bool,
|
||||
@@ -1456,12 +1524,25 @@ class ParsedDraft {
|
||||
categoryIcon = j['categoryIcon'] as String,
|
||||
amount = (j['amount'] as num).toDouble(),
|
||||
paymentMethod = j['paymentMethod'] as String?,
|
||||
transferDirection = j['transferDirection'] as String?,
|
||||
counterparty = j['counterparty'] as String?,
|
||||
note = j['note'] as String,
|
||||
type = switch (j['type']?.toString().toLowerCase()) {
|
||||
'income' => 'income',
|
||||
'expense' => 'expense',
|
||||
'transfer' => 'transfer',
|
||||
_ => 'unknown',
|
||||
};
|
||||
|
||||
bool get isIncome =>
|
||||
type == 'income' || type == 'transfer' && transferDirection == 'in';
|
||||
String get typeLabel => type == 'transfer'
|
||||
? transferDirection == 'in'
|
||||
? '转入'
|
||||
: '转出'
|
||||
: isIncome
|
||||
? '收入'
|
||||
: '支出';
|
||||
}
|
||||
|
||||
class ParseApi {
|
||||
@@ -1481,8 +1562,8 @@ class ParseApi {
|
||||
String source,
|
||||
String sourceText,
|
||||
) async {
|
||||
if (d.type != 'income' && d.type != 'expense') {
|
||||
throw StateError('请先确认收入或支出类型');
|
||||
if (d.type != 'income' && d.type != 'expense' && d.type != 'transfer') {
|
||||
throw StateError('请先确认账单类型');
|
||||
}
|
||||
return TxApi.create(
|
||||
categoryId: d.categoryId,
|
||||
@@ -1492,6 +1573,8 @@ class ParseApi {
|
||||
paymentMethod: d.paymentMethod,
|
||||
source: source,
|
||||
sourceText: sourceText,
|
||||
transferDirection: d.transferDirection,
|
||||
counterparty: d.counterparty,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import 'dart:convert';
|
||||
import 'dart:math';
|
||||
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter_secure_storage/flutter_secure_storage.dart';
|
||||
import 'package:path/path.dart' as p;
|
||||
import 'package:path_provider/path_provider.dart';
|
||||
@@ -13,6 +14,16 @@ class LocalDatabase {
|
||||
static final instance = LocalDatabase._();
|
||||
static const _secureStorage = FlutterSecureStorage();
|
||||
|
||||
@visibleForTesting
|
||||
static LocalDatabase inMemoryForTesting() {
|
||||
final database = LocalDatabase._();
|
||||
database._database = sqlite3.openInMemory();
|
||||
database._namespace = 'test';
|
||||
database._migrate();
|
||||
database._seedDefaults();
|
||||
return database;
|
||||
}
|
||||
|
||||
Database? _database;
|
||||
String? _namespace;
|
||||
|
||||
@@ -102,9 +113,16 @@ class LocalDatabase {
|
||||
amount REAL NOT NULL,
|
||||
note TEXT,
|
||||
payment_method TEXT,
|
||||
transfer_direction TEXT,
|
||||
counterparty TEXT,
|
||||
source TEXT NOT NULL DEFAULT 'manual',
|
||||
source_text TEXT,
|
||||
client_request_id TEXT,
|
||||
provider TEXT,
|
||||
provider_transaction_id TEXT,
|
||||
recognition_occurrence_id TEXT,
|
||||
evidence_fingerprint TEXT,
|
||||
recognition_confidence TEXT,
|
||||
occurred_at TEXT NOT NULL,
|
||||
is_deleted INTEGER NOT NULL DEFAULT 0,
|
||||
deleted_at TEXT,
|
||||
@@ -165,11 +183,37 @@ class LocalDatabase {
|
||||
if (!transactionColumns.contains('client_request_id')) {
|
||||
_db.execute('ALTER TABLE transactions ADD COLUMN client_request_id TEXT');
|
||||
}
|
||||
const addedTransactionColumns = <String, String>{
|
||||
'transfer_direction': 'TEXT',
|
||||
'counterparty': 'TEXT',
|
||||
'provider': 'TEXT',
|
||||
'provider_transaction_id': 'TEXT',
|
||||
'recognition_occurrence_id': 'TEXT',
|
||||
'evidence_fingerprint': 'TEXT',
|
||||
'recognition_confidence': 'TEXT',
|
||||
};
|
||||
for (final entry in addedTransactionColumns.entries) {
|
||||
if (!transactionColumns.contains(entry.key)) {
|
||||
_db.execute(
|
||||
'ALTER TABLE transactions ADD COLUMN ${entry.key} ${entry.value}',
|
||||
);
|
||||
}
|
||||
}
|
||||
_db.execute('''
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS ux_local_transactions_client_request
|
||||
ON transactions (client_request_id)
|
||||
WHERE client_request_id IS NOT NULL
|
||||
''');
|
||||
_db.execute('''
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS ux_local_transactions_occurrence
|
||||
ON transactions (recognition_occurrence_id)
|
||||
WHERE recognition_occurrence_id IS NOT NULL
|
||||
''');
|
||||
_db.execute('''
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS ux_local_transactions_provider_tx
|
||||
ON transactions (provider, provider_transaction_id)
|
||||
WHERE provider IS NOT NULL AND provider_transaction_id IS NOT NULL
|
||||
''');
|
||||
final conflictColumns = _db
|
||||
.select('PRAGMA table_info(sync_conflicts)')
|
||||
.map((row) => row['name'])
|
||||
@@ -659,7 +703,7 @@ class LocalDatabase {
|
||||
value['categoryName'] ?? '其他',
|
||||
value['categoryIcon'] ?? 'tag',
|
||||
value['categoryColor'] ?? 'mint',
|
||||
value['type'],
|
||||
_categoryType(value),
|
||||
DateTime.now().toUtc().toIso8601String(),
|
||||
],
|
||||
);
|
||||
@@ -668,8 +712,11 @@ class LocalDatabase {
|
||||
'''
|
||||
INSERT INTO transactions
|
||||
(id, ledger_id, category_id, type, amount, note, payment_method,
|
||||
source, source_text, occurred_at, is_deleted, deleted_at, sync_state, updated_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, NULL, 'synced', ?)
|
||||
transfer_direction, counterparty, source, source_text, client_request_id,
|
||||
provider, provider_transaction_id, recognition_occurrence_id,
|
||||
evidence_fingerprint, recognition_confidence,
|
||||
occurred_at, is_deleted, deleted_at, sync_state, updated_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, NULL, 'synced', ?)
|
||||
ON CONFLICT(id) DO UPDATE SET
|
||||
ledger_id = excluded.ledger_id,
|
||||
category_id = excluded.category_id,
|
||||
@@ -677,8 +724,16 @@ class LocalDatabase {
|
||||
amount = excluded.amount,
|
||||
note = excluded.note,
|
||||
payment_method = excluded.payment_method,
|
||||
transfer_direction = excluded.transfer_direction,
|
||||
counterparty = excluded.counterparty,
|
||||
source = excluded.source,
|
||||
source_text = excluded.source_text,
|
||||
client_request_id = excluded.client_request_id,
|
||||
provider = excluded.provider,
|
||||
provider_transaction_id = excluded.provider_transaction_id,
|
||||
recognition_occurrence_id = excluded.recognition_occurrence_id,
|
||||
evidence_fingerprint = excluded.evidence_fingerprint,
|
||||
recognition_confidence = excluded.recognition_confidence,
|
||||
occurred_at = excluded.occurred_at,
|
||||
is_deleted = excluded.is_deleted,
|
||||
sync_state = 'synced',
|
||||
@@ -692,8 +747,16 @@ class LocalDatabase {
|
||||
value['amount'],
|
||||
value['note'],
|
||||
value['paymentMethod'],
|
||||
value['transferDirection'],
|
||||
value['counterparty'],
|
||||
value['source'] ?? 'manual',
|
||||
value['sourceText'],
|
||||
value['clientRequestId'],
|
||||
value['provider'],
|
||||
value['providerTransactionId'],
|
||||
value['recognitionOccurrenceId'],
|
||||
value['evidenceFingerprint'],
|
||||
value['recognitionConfidence'],
|
||||
DateTime.parse(value['occurredAt'] as String).toUtc().toIso8601String(),
|
||||
value['isDeleted'] == true ? 1 : 0,
|
||||
value['updatedAt'] ?? DateTime.now().toUtc().toIso8601String(),
|
||||
@@ -902,25 +965,20 @@ class LocalDatabase {
|
||||
}
|
||||
|
||||
Map<String, dynamic> createTransaction(Map<String, dynamic> value) {
|
||||
final clientRequestId = value['clientRequestId'] as String?;
|
||||
if (clientRequestId != null && clientRequestId.isNotEmpty) {
|
||||
final existing = _db.select(
|
||||
'SELECT id FROM transactions WHERE client_request_id = ? LIMIT 1',
|
||||
[clientRequestId],
|
||||
);
|
||||
if (existing.isNotEmpty) {
|
||||
return transaction(
|
||||
(existing.first['id'] as num).toInt(),
|
||||
includeDeleted: true,
|
||||
)!;
|
||||
}
|
||||
final existingId = _existingTransactionId(value);
|
||||
if (existingId != null) {
|
||||
return transaction(existingId, includeDeleted: true)!;
|
||||
}
|
||||
final clientRequestId = _identity(value['clientRequestId']);
|
||||
final occurrenceId = _identity(value['recognitionOccurrenceId']);
|
||||
final provider = _identity(value['provider']);
|
||||
final providerTransactionId = _identity(value['providerTransactionId']);
|
||||
final categoryId = (value['categoryId'] as num).toInt();
|
||||
final category = _db.select(
|
||||
'SELECT * FROM categories WHERE id = ? AND is_deleted = 0',
|
||||
[categoryId],
|
||||
);
|
||||
if (category.isEmpty || category.first['type'] != value['type']) {
|
||||
if (category.isEmpty || category.first['type'] != _categoryType(value)) {
|
||||
throw StateError('分类与收支类型不一致');
|
||||
}
|
||||
final id = _nextNegativeId('transactions');
|
||||
@@ -930,8 +988,11 @@ class LocalDatabase {
|
||||
'''
|
||||
INSERT INTO transactions
|
||||
(id, ledger_id, category_id, type, amount, note, payment_method,
|
||||
source, source_text, client_request_id, occurred_at, is_deleted, sync_state, updated_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 0, 'local', ?)
|
||||
transfer_direction, counterparty, source, source_text, client_request_id,
|
||||
provider, provider_transaction_id, recognition_occurrence_id,
|
||||
evidence_fingerprint, recognition_confidence,
|
||||
occurred_at, is_deleted, sync_state, updated_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 0, 'local', ?)
|
||||
''',
|
||||
[
|
||||
id,
|
||||
@@ -941,9 +1002,16 @@ class LocalDatabase {
|
||||
value['amount'],
|
||||
value['note'],
|
||||
value['paymentMethod'],
|
||||
value['transferDirection'],
|
||||
value['counterparty'],
|
||||
value['source'] ?? 'manual',
|
||||
value['sourceText'],
|
||||
clientRequestId,
|
||||
provider,
|
||||
providerTransactionId,
|
||||
occurrenceId,
|
||||
value['evidenceFingerprint'],
|
||||
value['recognitionConfidence'],
|
||||
occurredAt,
|
||||
now,
|
||||
],
|
||||
@@ -959,14 +1027,7 @@ class LocalDatabase {
|
||||
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 existed = _existingTransactionId(value) != null;
|
||||
final transaction = createTransaction(value);
|
||||
created.add(transaction);
|
||||
final id = (transaction['id'] as num).toInt();
|
||||
@@ -1000,13 +1061,14 @@ class LocalDatabase {
|
||||
'SELECT type FROM categories WHERE id = ? AND is_deleted = 0',
|
||||
[value['categoryId']],
|
||||
);
|
||||
if (category.isEmpty || category.first['type'] != value['type']) {
|
||||
if (category.isEmpty || category.first['type'] != _categoryType(value)) {
|
||||
throw StateError('分类与收支类型不一致');
|
||||
}
|
||||
_db.execute(
|
||||
'''
|
||||
UPDATE transactions SET ledger_id = ?, category_id = ?, type = ?,
|
||||
amount = ?, note = ?, payment_method = ?, occurred_at = ?,
|
||||
amount = ?, note = ?, payment_method = ?, transfer_direction = ?,
|
||||
counterparty = ?, occurred_at = ?,
|
||||
sync_state = 'local', updated_at = ? WHERE id = ?
|
||||
''',
|
||||
[
|
||||
@@ -1016,6 +1078,8 @@ class LocalDatabase {
|
||||
value['amount'],
|
||||
value['note'],
|
||||
value['paymentMethod'],
|
||||
value['transferDirection'],
|
||||
value['counterparty'],
|
||||
value['occurredAt'],
|
||||
DateTime.now().toUtc().toIso8601String(),
|
||||
id,
|
||||
@@ -1140,7 +1204,7 @@ class LocalDatabase {
|
||||
ShanghaiTime.civilToUtc(start),
|
||||
ShanghaiTime.civilToUtc(end),
|
||||
);
|
||||
final expenses = items.where((item) => item['type'] == 'expense').toList();
|
||||
final expenses = items.where(_isExpense).toList();
|
||||
final expense = _sumType(items, 'expense');
|
||||
final income = _sumType(items, 'income');
|
||||
final categories = <int, Map<String, dynamic>>{};
|
||||
@@ -1236,7 +1300,7 @@ class LocalDatabase {
|
||||
ShanghaiTime.civilToUtc(start),
|
||||
ShanghaiTime.civilToUtc(end),
|
||||
);
|
||||
final expenses = items.where((item) => item['type'] == 'expense').toList();
|
||||
final expenses = items.where(_isExpense).toList();
|
||||
final grouped = <DateTime, List<Map<String, dynamic>>>{};
|
||||
for (final item in expenses) {
|
||||
final local = ShanghaiTime.parseCivil(item['occurredAt'] as String);
|
||||
@@ -1331,13 +1395,15 @@ class LocalDatabase {
|
||||
final keyword = query?.trim().toLowerCase();
|
||||
if (keyword != null && keyword.isNotEmpty) {
|
||||
final haystack =
|
||||
'${item['note'] ?? ''} ${item['categoryName']} ${item['sourceText'] ?? ''}'
|
||||
'${item['note'] ?? ''} ${item['counterparty'] ?? ''} '
|
||||
'${item['categoryName']} ${item['sourceText'] ?? ''}'
|
||||
.toLowerCase();
|
||||
if (!haystack.contains(keyword)) return false;
|
||||
}
|
||||
if (aiOnly && item['source'] == 'manual') return false;
|
||||
if (categoryId != null && item['categoryId'] != categoryId)
|
||||
if (categoryId != null && item['categoryId'] != categoryId) {
|
||||
return false;
|
||||
}
|
||||
if (type != null && item['type'] != type) return false;
|
||||
if (minAmount != null && amount < minAmount) return false;
|
||||
if (maxAmount != null && amount > maxAmount) return false;
|
||||
@@ -1390,7 +1456,7 @@ class LocalDatabase {
|
||||
final spent = monthItems
|
||||
.where(
|
||||
(item) =>
|
||||
item['type'] == 'expense' &&
|
||||
_isExpense(item) &&
|
||||
(categoryKey == 0 || item['categoryId'] == categoryKey),
|
||||
)
|
||||
.fold<double>(
|
||||
@@ -1526,18 +1592,83 @@ class LocalDatabase {
|
||||
'amount': (row['amount'] as num).toDouble(),
|
||||
'note': row['note'] as String?,
|
||||
'paymentMethod': row['payment_method'] as String?,
|
||||
'transferDirection': row['transfer_direction'] as String?,
|
||||
'counterparty': row['counterparty'] as String?,
|
||||
'source': row['source'] as String,
|
||||
'sourceText': row['source_text'] as String?,
|
||||
'clientRequestId': row['client_request_id'] as String?,
|
||||
'provider': row['provider'] as String?,
|
||||
'providerTransactionId': row['provider_transaction_id'] as String?,
|
||||
'recognitionOccurrenceId': row['recognition_occurrence_id'] as String?,
|
||||
'evidenceFingerprint': row['evidence_fingerprint'] as String?,
|
||||
'recognitionConfidence': row['recognition_confidence'] as String?,
|
||||
'occurredAt': row['occurred_at'] as String,
|
||||
'isDeleted': (row['is_deleted'] as num).toInt() == 1,
|
||||
'updatedAt': row['updated_at'] as String,
|
||||
};
|
||||
|
||||
double _sumType(List<Map<String, dynamic>> items, String type) => items
|
||||
.where((item) => item['type'] == type)
|
||||
.where((item) => type == 'income' ? _isIncome(item) : _isExpense(item))
|
||||
.fold<double>(0, (sum, item) => sum + (item['amount'] as num).toDouble());
|
||||
|
||||
bool _isIncome(Map<String, dynamic> item) =>
|
||||
item['type'] == 'income' ||
|
||||
item['type'] == 'transfer' && item['transferDirection'] == 'in';
|
||||
|
||||
bool _isExpense(Map<String, dynamic> item) =>
|
||||
item['type'] == 'expense' ||
|
||||
item['type'] == 'transfer' && item['transferDirection'] == 'out';
|
||||
|
||||
String _categoryType(Map<String, dynamic> value) {
|
||||
final type = value['type'] as String;
|
||||
final direction = value['transferDirection'] as String?;
|
||||
if (type == 'transfer') {
|
||||
if (direction == 'in') return 'income';
|
||||
if (direction == 'out') return 'expense';
|
||||
throw StateError('转账必须选择转入或转出');
|
||||
}
|
||||
if (direction != null && direction.isNotEmpty) {
|
||||
throw StateError('非转账账单不能设置转账方向');
|
||||
}
|
||||
return type;
|
||||
}
|
||||
|
||||
int? _existingTransactionId(Map<String, dynamic> value) {
|
||||
final provider = _identity(value['provider']);
|
||||
final providerTransactionId = _identity(value['providerTransactionId']);
|
||||
if (provider != null && providerTransactionId != null) {
|
||||
final rows = _db.select(
|
||||
'SELECT id FROM transactions '
|
||||
'WHERE provider = ? AND provider_transaction_id = ? LIMIT 1',
|
||||
[provider, providerTransactionId],
|
||||
);
|
||||
if (rows.isNotEmpty) return (rows.first['id'] as num).toInt();
|
||||
}
|
||||
final occurrenceId = _identity(value['recognitionOccurrenceId']);
|
||||
if (occurrenceId != null) {
|
||||
final rows = _db.select(
|
||||
'SELECT id FROM transactions '
|
||||
'WHERE recognition_occurrence_id = ? LIMIT 1',
|
||||
[occurrenceId],
|
||||
);
|
||||
if (rows.isNotEmpty) return (rows.first['id'] as num).toInt();
|
||||
}
|
||||
final clientRequestId = _identity(value['clientRequestId']);
|
||||
if (clientRequestId != null) {
|
||||
final rows = _db.select(
|
||||
'SELECT id FROM transactions WHERE client_request_id = ? LIMIT 1',
|
||||
[clientRequestId],
|
||||
);
|
||||
if (rows.isNotEmpty) return (rows.first['id'] as num).toInt();
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
String? _identity(Object? value) {
|
||||
final normalized = value?.toString().trim();
|
||||
return normalized == null || normalized.isEmpty ? null : normalized;
|
||||
}
|
||||
|
||||
String _monthDay(DateTime date) =>
|
||||
'${date.month.toString().padLeft(2, '0')}月${date.day.toString().padLeft(2, '0')}日';
|
||||
}
|
||||
|
||||
@@ -194,6 +194,13 @@ class RecognitionImportService {
|
||||
sourceText: candidate.sourceText,
|
||||
occurredAt: ShanghaiTime.toCivil(occurredUtc),
|
||||
clientRequestId: candidate.clientRequestId,
|
||||
transferDirection: candidate.transferDirection,
|
||||
counterparty: candidate.counterparty,
|
||||
provider: candidate.provider,
|
||||
providerTransactionId: candidate.providerTransactionId,
|
||||
recognitionOccurrenceId: candidate.recognitionOccurrenceId,
|
||||
evidenceFingerprint: candidate.evidenceFingerprint,
|
||||
recognitionConfidence: candidate.identityConfidence,
|
||||
);
|
||||
await ScreenshotChannel.acknowledgeRecognitionCandidate(
|
||||
candidate.id,
|
||||
@@ -227,6 +234,13 @@ class RecognitionImportService {
|
||||
source: candidate.source,
|
||||
sourceText: candidate.sourceText,
|
||||
occurredAt: ShanghaiTime.toCivil(occurredUtc),
|
||||
transferDirection: candidate.transferDirection,
|
||||
counterparty: candidate.counterparty,
|
||||
provider: candidate.provider,
|
||||
providerTransactionId: candidate.providerTransactionId,
|
||||
recognitionOccurrenceId: candidate.recognitionOccurrenceId,
|
||||
evidenceFingerprint: candidate.evidenceFingerprint,
|
||||
recognitionConfidence: candidate.identityConfidence,
|
||||
),
|
||||
);
|
||||
}
|
||||
@@ -254,11 +268,23 @@ class RecognitionImportService {
|
||||
RecognitionCandidate candidate,
|
||||
Map<String, List<CategoryItem>> cache,
|
||||
) async {
|
||||
if (candidate.type != 'income' && candidate.type != 'expense') {
|
||||
if (candidate.type != 'income' &&
|
||||
candidate.type != 'expense' &&
|
||||
candidate.type != 'transfer') {
|
||||
throw StateError('识别结果缺少明确的收支类型');
|
||||
}
|
||||
final categories = cache[candidate.type] ??= await TxApi.categories(
|
||||
candidate.type,
|
||||
if (candidate.type == 'transfer' &&
|
||||
candidate.transferDirection != 'in' &&
|
||||
candidate.transferDirection != 'out') {
|
||||
throw StateError('转账识别结果缺少方向');
|
||||
}
|
||||
final categoryType = candidate.type == 'transfer'
|
||||
? candidate.transferDirection == 'in'
|
||||
? 'income'
|
||||
: 'expense'
|
||||
: candidate.type;
|
||||
final categories = cache[categoryType] ??= await TxApi.categories(
|
||||
categoryType,
|
||||
);
|
||||
if (categories.isEmpty) throw StateError('当前账本没有可用分类');
|
||||
return categories
|
||||
@@ -315,16 +341,16 @@ class RecognitionImportService {
|
||||
width: 42,
|
||||
height: 42,
|
||||
decoration: BoxDecoration(
|
||||
color: candidate.type == 'income'
|
||||
color: candidate.isIncome
|
||||
? palette.primaryBackground
|
||||
: palette.expenseBackground,
|
||||
borderRadius: BorderRadius.circular(13),
|
||||
),
|
||||
child: Icon(
|
||||
candidate.type == 'income'
|
||||
candidate.isIncome
|
||||
? Icons.south_west_rounded
|
||||
: Icons.north_east_rounded,
|
||||
color: candidate.type == 'income'
|
||||
color: candidate.isIncome
|
||||
? AppTheme.primary
|
||||
: AppTheme.red,
|
||||
),
|
||||
@@ -343,7 +369,7 @@ class RecognitionImportService {
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
Text(
|
||||
'${candidate.appName} · ${candidate.type == 'income' ? '收入' : '支出'}',
|
||||
'${candidate.appName} · ${candidate.typeLabel}',
|
||||
style: TextStyle(
|
||||
color: palette.text2,
|
||||
fontSize: 12,
|
||||
@@ -353,9 +379,9 @@ class RecognitionImportService {
|
||||
),
|
||||
),
|
||||
Text(
|
||||
'${candidate.type == 'income' ? '+' : '-'}¥${candidate.amount.toStringAsFixed(2)}',
|
||||
'${candidate.isIncome ? '+' : '-'}¥${candidate.amount.toStringAsFixed(2)}',
|
||||
style: TextStyle(
|
||||
color: candidate.type == 'income'
|
||||
color: candidate.isIncome
|
||||
? AppTheme.primary
|
||||
: AppTheme.red,
|
||||
fontSize: 18,
|
||||
|
||||
@@ -136,7 +136,11 @@ class RecognitionCandidate {
|
||||
final String id, clientRequestId, state, confidence, type, source, appName;
|
||||
final double amount;
|
||||
final String? merchant, orderId, sourceText, note;
|
||||
final String? transferDirection, counterparty;
|
||||
final String? provider, providerTransactionId, recognitionOccurrenceId;
|
||||
final String? evidenceFingerprint;
|
||||
final String recognitionKind, amountSource;
|
||||
final String identityConfidence;
|
||||
final String? categoryHint, resultFingerprint;
|
||||
final String? batchId, aiAction, aiReason;
|
||||
final int? categoryId;
|
||||
@@ -155,23 +159,39 @@ class RecognitionCandidate {
|
||||
orderId = value['orderId'] as String?,
|
||||
sourceText = value['sourceText'] as String?,
|
||||
note = value['note'] as String?,
|
||||
transferDirection = value['transferDirection']?.toString(),
|
||||
counterparty = value['counterparty']?.toString(),
|
||||
provider = value['provider']?.toString(),
|
||||
providerTransactionId = value['providerTransactionId']?.toString(),
|
||||
recognitionOccurrenceId = value['recognitionOccurrenceId']?.toString(),
|
||||
evidenceFingerprint = value['evidenceFingerprint']?.toString(),
|
||||
recognitionKind = value['recognitionKind']?.toString() ?? 'payment',
|
||||
categoryHint = value['categoryHint']?.toString(),
|
||||
categoryId = (value['categoryId'] as num?)?.toInt(),
|
||||
amountSource = value['amountSource']?.toString() ?? 'result',
|
||||
resultFingerprint = value['resultFingerprint']?.toString(),
|
||||
identityConfidence = value['identityConfidence']?.toString() ?? 'strong',
|
||||
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';
|
||||
bool get isIncome =>
|
||||
type == 'income' || type == 'transfer' && transferDirection == 'in';
|
||||
String get typeLabel => type == 'transfer'
|
||||
? transferDirection == 'in'
|
||||
? '转入'
|
||||
: '转出'
|
||||
: isIncome
|
||||
? '收入'
|
||||
: '支出';
|
||||
}
|
||||
|
||||
class RecognitionBatchItem {
|
||||
final String candidateId, action, reason, state, type;
|
||||
final double amount;
|
||||
final String? merchant;
|
||||
final String? merchant, transferDirection;
|
||||
final bool canRestore;
|
||||
|
||||
RecognitionBatchItem.fromJson(Map<String, dynamic> value)
|
||||
@@ -182,7 +202,11 @@ class RecognitionBatchItem {
|
||||
type = value['type']?.toString() ?? 'expense',
|
||||
amount = (value['amount'] as num?)?.toDouble() ?? 0,
|
||||
merchant = value['merchant']?.toString(),
|
||||
transferDirection = value['transferDirection']?.toString(),
|
||||
canRestore = value['canRestore'] as bool? ?? false;
|
||||
|
||||
bool get isIncome =>
|
||||
type == 'income' || type == 'transfer' && transferDirection == 'in';
|
||||
}
|
||||
|
||||
class RecognitionBatch {
|
||||
@@ -438,6 +462,26 @@ class ScreenshotChannel {
|
||||
}
|
||||
}
|
||||
|
||||
static Future<List<RecognitionCandidate>> listRecognitionCandidates() async {
|
||||
try {
|
||||
final values =
|
||||
await _channel.invokeMethod<List<Object?>>(
|
||||
'listRecognitionCandidates',
|
||||
) ??
|
||||
const [];
|
||||
return values
|
||||
.whereType<String>()
|
||||
.map(
|
||||
(value) => RecognitionCandidate.fromJson(
|
||||
jsonDecode(value) as Map<String, dynamic>,
|
||||
),
|
||||
)
|
||||
.toList(growable: false);
|
||||
} on MissingPluginException {
|
||||
return const [];
|
||||
}
|
||||
}
|
||||
|
||||
static Future<bool> acknowledgeRecognitionCandidate(
|
||||
String id,
|
||||
String state, {
|
||||
|
||||
Reference in New Issue
Block a user