Files
jizhi/frontend/lib/shared/services/local_database.dart
T

1772 lines
59 KiB
Dart

import 'dart:convert';
import 'dart:io';
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';
import 'package:miaoji_zhang/shared/services/shanghai_time.dart';
import 'package:sqlite3/sqlite3.dart';
class LocalDatabase {
LocalDatabase._();
static final instance = LocalDatabase._();
static const _secureStorage = FlutterSecureStorage();
@visibleForTesting
static LocalDatabase inMemoryForTesting({bool seedDefaults = true}) {
final database = LocalDatabase._();
database._database = sqlite3.openInMemory();
database._namespace = 'test';
database._migrate();
if (seedDefaults) database._seedDefaults();
return database;
}
Database? _database;
String? _namespace;
String? get namespace => _namespace;
Future<bool> namespaceExists(String namespace) async {
final safeNamespace = namespace.replaceAll(RegExp(r'[^a-zA-Z0-9_-]'), '_');
final directory = await getApplicationSupportDirectory();
return File(p.join(directory.path, 'jizhi_$safeNamespace.db')).exists();
}
Database get _db {
final database = _database;
if (database == null) throw StateError('本地数据库尚未初始化');
return database;
}
Future<void> openNamespace(String namespace) async {
if (_namespace == namespace && _database != null) return;
close();
final safeNamespace = namespace.replaceAll(RegExp(r'[^a-zA-Z0-9_-]'), '_');
final directory = await getApplicationSupportDirectory();
final databasePath = p.join(directory.path, 'jizhi_$safeNamespace.db');
final keyName = 'local_db_key_$safeNamespace';
var key = await _secureStorage.read(key: keyName);
if (key == null) {
final random = Random.secure();
key = List<int>.generate(
32,
(_) => random.nextInt(256),
).map((value) => value.toRadixString(16).padLeft(2, '0')).join();
await _secureStorage.write(key: keyName, value: key);
}
final database = sqlite3.open(databasePath);
try {
database.execute("PRAGMA key = \"x'$key'\"");
database.execute('PRAGMA foreign_keys = ON');
database.execute('PRAGMA journal_mode = WAL');
database.execute('PRAGMA cipher_memory_security = ON');
database.select('SELECT count(*) FROM sqlite_master');
_database = database;
_namespace = namespace;
_migrate();
if (namespace == 'guest') {
_seedDefaults();
} else {
ensureDefaultCategories();
}
} catch (_) {
database.close();
rethrow;
}
}
void close() {
_database?.close();
_database = null;
_namespace = null;
}
void _migrate() {
_db.execute('''
CREATE TABLE IF NOT EXISTS local_meta (
key TEXT PRIMARY KEY,
value TEXT NOT NULL
)
''');
_db.execute('''
CREATE TABLE IF NOT EXISTS ledgers (
id INTEGER PRIMARY KEY,
name TEXT NOT NULL,
icon_key TEXT NOT NULL DEFAULT 'wallet',
is_default INTEGER NOT NULL DEFAULT 0,
transaction_count INTEGER NOT NULL DEFAULT 0,
updated_at TEXT NOT NULL
)
''');
_db.execute('''
CREATE TABLE IF NOT EXISTS categories (
id INTEGER PRIMARY KEY,
name TEXT NOT NULL,
icon_key TEXT NOT NULL,
color_key TEXT NOT NULL DEFAULT 'mint',
type TEXT NOT NULL,
sort_order INTEGER NOT NULL,
is_custom INTEGER NOT NULL DEFAULT 0,
is_deleted INTEGER NOT NULL DEFAULT 0,
updated_at TEXT NOT NULL
)
''');
_db.execute('''
CREATE TABLE IF NOT EXISTS transactions (
id INTEGER PRIMARY KEY,
ledger_id INTEGER NOT NULL,
category_id INTEGER NOT NULL,
type TEXT NOT NULL,
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,
sync_state TEXT NOT NULL DEFAULT 'local',
updated_at TEXT NOT NULL
)
''');
_db.execute('''
CREATE INDEX IF NOT EXISTS ix_local_transactions_period
ON transactions (ledger_id, occurred_at)
''');
_db.execute('''
CREATE TABLE IF NOT EXISTS budgets (
ledger_id INTEGER NOT NULL,
period INTEGER NOT NULL,
category_key INTEGER NOT NULL,
amount REAL NOT NULL,
is_recurring INTEGER NOT NULL DEFAULT 0,
updated_at TEXT NOT NULL,
PRIMARY KEY (ledger_id, period, category_key)
)
''');
_db.execute('''
CREATE TABLE IF NOT EXISTS sync_queue (
id INTEGER PRIMARY KEY AUTOINCREMENT,
entity_type TEXT NOT NULL,
entity_id INTEGER NOT NULL,
operation TEXT NOT NULL,
payload TEXT NOT NULL,
created_at TEXT NOT NULL,
retry_count INTEGER NOT NULL DEFAULT 0
)
''');
_db.execute('''
CREATE TABLE IF NOT EXISTS sync_conflicts (
id INTEGER PRIMARY KEY AUTOINCREMENT,
entity_type TEXT NOT NULL,
entity_id INTEGER NOT NULL,
operation TEXT NOT NULL DEFAULT 'update',
local_payload TEXT NOT NULL,
remote_payload TEXT NOT NULL,
created_at TEXT NOT NULL,
resolved_at TEXT
)
''');
_db.execute('''
CREATE TABLE IF NOT EXISTS local_id_map (
entity_type TEXT NOT NULL,
local_id INTEGER NOT NULL,
remote_id INTEGER NOT NULL,
PRIMARY KEY (entity_type, local_id)
)
''');
final transactionColumns = _db
.select('PRAGMA table_info(transactions)')
.map((row) => row['name'])
.toSet();
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'])
.toSet();
if (!conflictColumns.contains('operation')) {
_db.execute(
"ALTER TABLE sync_conflicts ADD COLUMN operation TEXT NOT NULL DEFAULT 'update'",
);
}
final recognitionTimestampRepaired = _db.select(
"SELECT 1 FROM local_meta WHERE key = 'recognition_timestamp_repair_v1'",
);
if (recognitionTimestampRepaired.isEmpty) {
_db.execute("""UPDATE transactions
SET occurred_at = updated_at
WHERE source IN ('accessibility', 'notification', 'recognition_ai')
AND occurred_at < '2000-01-01T00:00:00Z'""");
_db.execute("""INSERT OR REPLACE INTO local_meta (key, value)
VALUES ('recognition_timestamp_repair_v1', 'done')""");
}
}
void _seedDefaults() {
final now = DateTime.now().toUtc().toIso8601String();
_db.execute(
'''INSERT OR IGNORE INTO ledgers
(id, name, icon_key, is_default, transaction_count, updated_at)
VALUES (1, '日常账本', 'wallet', 1, 0, ?)''',
[now],
);
const defaults = <(int, String, String, String, String, int)>[
(1, '餐饮', 'food', 'coral', 'expense', 10),
(2, '饮品', 'drink', 'cyan', 'expense', 20),
(3, '购物', 'cart', 'blue', 'expense', 30),
(4, '交通', 'metro', 'teal', 'expense', 40),
(5, '住房', 'house', 'sand', 'expense', 50),
(6, '娱乐', 'game', 'violet', 'expense', 60),
(7, '医疗', 'medical', 'red', 'expense', 70),
(8, '学习', 'book', 'indigo', 'expense', 80),
(9, '服饰', 'clothes', 'plum', 'expense', 90),
(10, '人情', 'gift', 'rose', 'expense', 100),
(11, '旅行', 'travel', 'sky', 'expense', 110),
(12, '其他', 'tag', 'graphite', 'expense', 120),
(101, '工资', 'money', 'mint', 'income', 10),
(102, '兼职', 'parttime', 'forest', 'income', 20),
(103, '理财', 'invest', 'navy', 'income', 30),
(104, '红包', 'redpacket', 'orange', 'income', 40),
(105, '报销', 'reimburse', 'amber', 'income', 50),
(106, '奖金', 'bonus', 'aqua', 'income', 60),
(107, '其他', 'tag', 'lime', 'income', 70),
];
final statement = _db.prepare('''
INSERT OR IGNORE INTO categories
(id, name, icon_key, color_key, type, sort_order, is_custom, is_deleted, updated_at)
VALUES (?, ?, ?, ?, ?, ?, 0, 0, ?)
''');
try {
for (final item in defaults) {
statement.execute([
item.$1,
item.$2,
item.$3,
item.$4,
item.$5,
item.$6,
now,
]);
}
} finally {
statement.close();
}
}
/// Ensures an account can still render the category picker and create a
/// manual transaction before its first successful category refresh.
///
/// Account fallback IDs mirror the server's seeded system categories. The
/// remote refresh remains authoritative and will update these rows in place.
void ensureDefaultCategories({String? type}) {
final isGuestNamespace = _namespace == 'guest';
const expense = <(int, String, String, String, String, int)>[
(1, '餐饮', 'food', 'coral', 'expense', 0),
(2, '饮品', 'cup', 'cyan', 'expense', 1),
(3, '购物', 'cart', 'blue', 'expense', 2),
(4, '交通', 'metro', 'teal', 'expense', 3),
(5, '住房', 'house', 'sand', 'expense', 4),
(6, '娱乐', 'game', 'violet', 'expense', 5),
(7, '医疗', 'pill', 'red', 'expense', 6),
(8, '学习', 'book', 'indigo', 'expense', 7),
(9, '服饰', 'shirt', 'plum', 'expense', 8),
(10, '人情', 'gift', 'rose', 'expense', 9),
(11, '旅行', 'plane', 'sky', 'expense', 10),
(12, '其他', 'tag', 'graphite', 'expense', 11),
];
const accountIncome = <(int, String, String, String, String, int)>[
(13, '工资', 'money', 'mint', 'income', 0),
(14, '兼职', 'briefcase', 'forest', 'income', 1),
(15, '理财', 'chart', 'navy', 'income', 2),
(16, '红包', 'gift', 'orange', 'income', 3),
(17, '报销', 'card', 'amber', 'income', 4),
(18, '奖金', 'sparkle', 'aqua', 'income', 5),
(19, '其他', 'tag', 'lime', 'income', 6),
];
const guestIncome = <(int, String, String, String, String, int)>[
(101, '工资', 'money', 'mint', 'income', 10),
(102, '兼职', 'parttime', 'forest', 'income', 20),
(103, '理财', 'invest', 'navy', 'income', 30),
(104, '红包', 'redpacket', 'orange', 'income', 40),
(105, '报销', 'reimburse', 'amber', 'income', 50),
(106, '奖金', 'bonus', 'aqua', 'income', 60),
(107, '其他', 'tag', 'lime', 'income', 70),
];
final catalogs = <String, List<(int, String, String, String, String, int)>>{
'expense': expense,
'income': isGuestNamespace ? guestIncome : accountIncome,
};
final now = DateTime.now().toUtc().toIso8601String();
for (final entry in catalogs.entries) {
if (type != null && entry.key != type) continue;
final active = _db.select(
'''SELECT 1 FROM categories
WHERE type = ? AND is_deleted = 0 AND is_custom = 0 LIMIT 1''',
[entry.key],
);
if (active.isNotEmpty) continue;
for (final item in entry.value) {
_db.execute(
'''
INSERT INTO categories
(id, name, icon_key, color_key, type, sort_order, is_custom, is_deleted, updated_at)
VALUES (?, ?, ?, ?, ?, ?, 0, 0, ?)
ON CONFLICT(id) DO UPDATE SET
name = excluded.name,
icon_key = excluded.icon_key,
color_key = excluded.color_key,
type = excluded.type,
sort_order = excluded.sort_order,
is_custom = 0,
is_deleted = 0,
updated_at = excluded.updated_at
''',
[item.$1, item.$2, item.$3, item.$4, item.$5, item.$6, now],
);
}
}
}
Map<String, dynamic> guestSnapshot() {
final customCategories = _db
.select('''
SELECT * FROM categories
WHERE is_custom = 1 AND is_deleted = 0
ORDER BY type, sort_order
''')
.map(_categoryMap)
.toList();
final transactions = _db
.select('''
SELECT t.*, c.name AS category_name, c.icon_key AS category_icon,
c.color_key AS category_color
FROM transactions t JOIN categories c ON c.id = t.category_id
WHERE t.is_deleted = 0
ORDER BY t.occurred_at, t.id
''')
.map(_transactionMap)
.toList();
final budgets = _db
.select('SELECT * FROM budgets ORDER BY period, category_key')
.map(
(row) => {
'period': (row['period'] as num).toInt(),
'categoryId': (row['category_key'] as num).toInt() == 0
? null
: (row['category_key'] as num).toInt(),
'amount': (row['amount'] as num).toDouble(),
'recurring': (row['is_recurring'] as num).toInt() == 1,
},
)
.toList();
return {
'hasData':
transactions.isNotEmpty ||
customCategories.isNotEmpty ||
budgets.isNotEmpty ||
ledgers().length > 1,
'categories': customCategories,
'transactions': transactions,
'budgets': budgets,
};
}
Map<String, dynamic> exportSnapshot() {
final ledgers = _db
.select('SELECT * FROM ledgers ORDER BY is_default DESC, id')
.map(
(row) => <String, dynamic>{
'id': (row['id'] as num).toInt(),
'name': row['name'] as String,
'iconKey': row['icon_key'] as String,
'isDefault': (row['is_default'] as num).toInt() == 1,
'updatedAt': row['updated_at'] as String,
},
)
.toList();
final categories = _db
.select('SELECT * FROM categories ORDER BY type, sort_order, id')
.map(
(row) => <String, dynamic>{
..._categoryMap(row),
'isDeleted': (row['is_deleted'] as num).toInt() == 1,
'updatedAt': row['updated_at'] as String,
},
)
.toList();
final transactions = _db
.select('''
SELECT t.*, c.name AS category_name, c.icon_key AS category_icon,
c.color_key AS category_color
FROM transactions t
JOIN categories c ON c.id = t.category_id
ORDER BY t.occurred_at, t.id
''')
.map(
(row) => <String, dynamic>{
..._transactionMap(row),
'deletedAt': row['deleted_at'] as String?,
},
)
.toList();
final budgets = _db
.select(
'SELECT * FROM budgets ORDER BY period, ledger_id, category_key',
)
.map(
(row) => <String, dynamic>{
'ledgerId': (row['ledger_id'] as num).toInt(),
'period': (row['period'] as num).toInt(),
'categoryId': (row['category_key'] as num).toInt() == 0
? null
: (row['category_key'] as num).toInt(),
'amount': (row['amount'] as num).toDouble(),
'recurring': (row['is_recurring'] as num).toInt() == 1,
'updatedAt': row['updated_at'] as String,
},
)
.toList();
return {
'schemaVersion': 1,
'exportedAt': DateTime.now().toUtc().toIso8601String(),
'ledgers': ledgers,
'categories': categories,
'transactions': transactions,
'budgets': budgets,
};
}
List<Map<String, dynamic>> ledgers() {
final counts = <int, int>{};
for (final row in _db.select('''
SELECT ledger_id, COUNT(*) AS tx_count
FROM transactions WHERE is_deleted = 0 GROUP BY ledger_id
''')) {
counts[(row['ledger_id'] as num).toInt()] = (row['tx_count'] as num)
.toInt();
}
return _db
.select('SELECT * FROM ledgers ORDER BY is_default DESC, id')
.map(
(row) => <String, dynamic>{
'id': (row['id'] as num).toInt(),
'name': row['name'] as String,
'iconKey': row['icon_key'] as String,
'isDefault': (row['is_default'] as num).toInt() == 1,
'txCount': counts[(row['id'] as num).toInt()] ?? 0,
},
)
.toList();
}
void cacheLedgers(List<dynamic> values) {
final now = DateTime.now().toUtc().toIso8601String();
_db.execute('BEGIN');
try {
final incomingIds = values
.map(
(value) => ((value as Map<String, dynamic>)['id'] as num).toInt(),
)
.toSet();
for (final row in _db.select('SELECT id FROM ledgers WHERE id > 0')) {
final id = (row['id'] as num).toInt();
if (incomingIds.contains(id)) continue;
final references =
_db
.select(
'''
SELECT
(SELECT COUNT(*) FROM transactions WHERE ledger_id = ?) +
(SELECT COUNT(*) FROM budgets WHERE ledger_id = ?) +
(SELECT COUNT(*) FROM sync_queue
WHERE entity_type = 'ledger' AND entity_id = ?) AS value
''',
[id, id, id],
)
.first['value']
as int;
if (references == 0) {
_db.execute('DELETE FROM ledgers WHERE id = ?', [id]);
}
}
for (final value in values) {
final item = value as Map<String, dynamic>;
_db.execute(
'''
INSERT INTO ledgers
(id, name, icon_key, is_default, transaction_count, updated_at)
VALUES (?, ?, ?, ?, ?, ?)
ON CONFLICT(id) DO UPDATE SET
name = excluded.name,
icon_key = excluded.icon_key,
is_default = excluded.is_default,
transaction_count = excluded.transaction_count,
updated_at = excluded.updated_at
''',
[
item['id'],
item['name'],
item['iconKey'] ?? 'wallet',
item['isDefault'] == true ? 1 : 0,
item['txCount'] ?? 0,
now,
],
);
}
_db.execute('COMMIT');
} catch (_) {
_db.execute('ROLLBACK');
rethrow;
}
}
int createLedger(String name) {
final id = _nextNegativeId('ledgers');
_db.execute(
'''
INSERT INTO ledgers
(id, name, icon_key, is_default, transaction_count, updated_at)
VALUES (?, ?, 'wallet', 0, 0, ?)
''',
[id, name, DateTime.now().toUtc().toIso8601String()],
);
return id;
}
void selectLedger(int id) {
_db.execute('UPDATE ledgers SET is_default = 0');
_db.execute('UPDATE ledgers SET is_default = 1 WHERE id = ?', [id]);
}
void renameLedger(int id, String name, String iconKey) {
_db.execute(
'UPDATE ledgers SET name = ?, icon_key = ?, updated_at = ? WHERE id = ?',
[name, iconKey, DateTime.now().toUtc().toIso8601String(), id],
);
}
void deleteLedger(int id) {
final count =
_db.select(
'SELECT COUNT(*) AS value FROM transactions WHERE ledger_id = ?',
[id],
).first['value']
as int;
final budgetCount =
_db.select(
'SELECT COUNT(*) AS value FROM budgets WHERE ledger_id = ?',
[id],
).first['value']
as int;
if (count > 0 || budgetCount > 0) {
throw StateError('仅可删除没有账单和预算的账本');
}
_db.execute('DELETE FROM ledgers WHERE id = ? AND is_default = 0', [id]);
}
List<Map<String, dynamic>> categories(String type) => _db
.select(
'''SELECT * FROM categories
WHERE type = ? AND is_deleted = 0
ORDER BY is_custom, sort_order, id''',
[type],
)
.map(_categoryMap)
.toList();
void cacheCategories(List<dynamic> values, {String? replaceType}) {
final now = DateTime.now().toUtc().toIso8601String();
if (replaceType != null) {
final incomingIds = values
.map(
(value) => ((value as Map<String, dynamic>)['id'] as num).toInt(),
)
.toSet();
for (final row in _db.select(
'SELECT id FROM categories WHERE id > 0 AND type = ?',
[replaceType],
)) {
final id = (row['id'] as num).toInt();
if (!incomingIds.contains(id)) {
_db.execute(
'UPDATE categories SET is_deleted = 1, updated_at = ? WHERE id = ?',
[now, id],
);
}
}
}
for (final value in values) {
final item = value as Map<String, dynamic>;
_db.execute(
'''
INSERT INTO categories
(id, name, icon_key, color_key, type, sort_order, is_custom, is_deleted, updated_at)
VALUES (?, ?, ?, ?, ?, ?, ?, 0, ?)
ON CONFLICT(id) DO UPDATE SET
name = excluded.name,
icon_key = excluded.icon_key,
color_key = excluded.color_key,
type = excluded.type,
sort_order = excluded.sort_order,
is_custom = excluded.is_custom,
is_deleted = 0,
updated_at = excluded.updated_at
''',
[
item['id'],
item['name'],
item['iconKey'] ?? 'tag',
item['colorKey'] ?? 'mint',
item['type'],
item['sortOrder'] ?? 0,
item['isCustom'] == true ? 1 : 0,
now,
],
);
}
}
Map<String, dynamic> createCategory(
String name,
String iconKey,
String colorKey,
String type,
) {
final id = _nextNegativeId('categories');
final sortOrder =
(_db.select(
'SELECT MAX(sort_order) AS value FROM categories WHERE type = ?',
[type],
).first['value']
as num?)
?.toInt() ??
0;
_db.execute(
'''
INSERT INTO categories
(id, name, icon_key, color_key, type, sort_order, is_custom, is_deleted, updated_at)
VALUES (?, ?, ?, ?, ?, ?, 1, 0, ?)
''',
[
id,
name,
iconKey,
colorKey,
type,
sortOrder + 10,
DateTime.now().toUtc().toIso8601String(),
],
);
return _categoryMap(
_db.select('SELECT * FROM categories WHERE id = ?', [id]).first,
);
}
Map<String, dynamic> updateCategory(
int id,
String name,
String iconKey,
String colorKey,
int sortOrder,
) {
_db.execute(
'''
UPDATE categories SET name = ?, icon_key = ?, color_key = ?,
sort_order = ?, updated_at = ? WHERE id = ? AND is_custom = 1
''',
[
name,
iconKey,
colorKey,
sortOrder,
DateTime.now().toUtc().toIso8601String(),
id,
],
);
return _categoryMap(
_db.select('SELECT * FROM categories WHERE id = ?', [id]).first,
);
}
void reorderCategories(String type, List<int> ids) {
_db.execute('BEGIN');
try {
for (var index = 0; index < ids.length; index++) {
_db.execute(
'UPDATE categories SET sort_order = ? WHERE id = ? AND type = ? AND is_custom = 1',
[(index + 1) * 10, ids[index], type],
);
}
_db.execute('COMMIT');
} catch (_) {
_db.execute('ROLLBACK');
rethrow;
}
}
void deleteCategory(int id) {
_db.execute(
'UPDATE categories SET is_deleted = 1, updated_at = ? WHERE id = ? AND is_custom = 1',
[DateTime.now().toUtc().toIso8601String(), id],
);
}
void cacheMonthSummary(Map<String, dynamic> summary) {
for (final day in summary['days'] as List<dynamic>? ?? const []) {
final group = day as Map<String, dynamic>;
for (final value in group['items'] as List<dynamic>? ?? const []) {
cacheTransaction(value as Map<String, dynamic>);
}
}
}
void cacheTransactions(Iterable<dynamic> values) {
for (final value in values) {
cacheTransaction(value as Map<String, dynamic>);
}
}
void cacheTransaction(Map<String, dynamic> value) {
final categoryId = (value['categoryId'] as num).toInt();
final categoryExists = _db.select('SELECT 1 FROM categories WHERE id = ?', [
categoryId,
]).isNotEmpty;
if (!categoryExists) {
_db.execute(
'''
INSERT INTO categories
(id, name, icon_key, color_key, type, sort_order, is_custom, is_deleted, updated_at)
VALUES (?, ?, ?, ?, ?, 999, 0, 0, ?)
''',
[
categoryId,
value['categoryName'] ?? '其他',
value['categoryIcon'] ?? 'tag',
value['categoryColor'] ?? 'mint',
_categoryType(value),
DateTime.now().toUtc().toIso8601String(),
],
);
}
_db.execute(
'''
INSERT INTO transactions
(id, ledger_id, category_id, type, amount, note, payment_method,
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,
type = excluded.type,
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',
updated_at = excluded.updated_at
''',
[
value['id'],
value['ledgerId'],
categoryId,
value['type'],
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(),
],
);
}
void replaceLocalTransaction(int localId, Map<String, dynamic> remote) {
final remoteId = (remote['id'] as num).toInt();
_db.execute('BEGIN');
try {
cacheTransaction(remote);
_saveIdMap('transaction', localId, remoteId);
_db.execute('DELETE FROM transactions WHERE id = ?', [localId]);
_db.execute('COMMIT');
} catch (_) {
_db.execute('ROLLBACK');
rethrow;
}
}
void enqueueSync(
String entityType,
int entityId,
String operation,
Map<String, dynamic> payload,
) {
_db.execute(
'''
INSERT INTO sync_queue
(entity_type, entity_id, operation, payload, created_at, retry_count)
VALUES (?, ?, ?, ?, ?, 0)
''',
[
entityType,
entityId,
operation,
jsonEncode(payload),
DateTime.now().toUtc().toIso8601String(),
],
);
}
int? remoteId(String entityType, int localId) {
if (localId >= 0) return localId;
final rows = _db.select(
'SELECT remote_id FROM local_id_map WHERE entity_type = ? AND local_id = ?',
[entityType, localId],
);
return rows.isEmpty ? null : (rows.first['remote_id'] as num).toInt();
}
void remapLedger(int localId, Map<String, dynamic> remote) {
final newId = (remote['id'] as num).toInt();
_db.execute('BEGIN');
try {
_db.execute(
'''
INSERT OR REPLACE INTO ledgers
(id, name, icon_key, is_default, transaction_count, updated_at)
SELECT ?, ?, ?, is_default, transaction_count, ?
FROM ledgers WHERE id = ?
''',
[
newId,
remote['name'],
remote['iconKey'] ?? 'wallet',
DateTime.now().toUtc().toIso8601String(),
localId,
],
);
_db.execute('UPDATE transactions SET ledger_id = ? WHERE ledger_id = ?', [
newId,
localId,
]);
_db.execute('UPDATE budgets SET ledger_id = ? WHERE ledger_id = ?', [
newId,
localId,
]);
_db.execute('DELETE FROM ledgers WHERE id = ?', [localId]);
_saveIdMap('ledger', localId, newId);
_db.execute('COMMIT');
} catch (_) {
_db.execute('ROLLBACK');
rethrow;
}
}
void remapCategory(int localId, Map<String, dynamic> remote) {
final newId = (remote['id'] as num).toInt();
_db.execute('BEGIN');
try {
_db.execute(
'''
INSERT OR REPLACE INTO categories
(id, name, icon_key, color_key, type, sort_order, is_custom, is_deleted, updated_at)
SELECT ?, ?, ?, ?, type, sort_order, 1, 0, ?
FROM categories WHERE id = ?
''',
[
newId,
remote['name'],
remote['iconKey'] ?? 'tag',
remote['colorKey'] ?? 'mint',
DateTime.now().toUtc().toIso8601String(),
localId,
],
);
_db.execute(
'UPDATE transactions SET category_id = ? WHERE category_id = ?',
[newId, localId],
);
_db.execute(
'UPDATE budgets SET category_key = ? WHERE category_key = ?',
[newId, localId],
);
_db.execute('DELETE FROM categories WHERE id = ?', [localId]);
_saveIdMap('category', localId, newId);
_db.execute('COMMIT');
} catch (_) {
_db.execute('ROLLBACK');
rethrow;
}
}
void _saveIdMap(String entityType, int localId, int remoteId) {
_db.execute(
'''
INSERT INTO local_id_map (entity_type, local_id, remote_id)
VALUES (?, ?, ?)
ON CONFLICT(entity_type, local_id) DO UPDATE SET remote_id = excluded.remote_id
''',
[entityType, localId, remoteId],
);
}
List<Map<String, dynamic>> pendingSync() => _db
.select('SELECT * FROM sync_queue ORDER BY id')
.map(
(row) => {
'id': (row['id'] as num).toInt(),
'entityType': row['entity_type'] as String,
'entityId': (row['entity_id'] as num).toInt(),
'operation': row['operation'] as String,
'payload':
jsonDecode(row['payload'] as String) as Map<String, dynamic>,
'retryCount': (row['retry_count'] as num).toInt(),
},
)
.toList();
void completeSync(int queueId) {
_db.execute('DELETE FROM sync_queue WHERE id = ?', [queueId]);
}
void failSync(int queueId) {
_db.execute(
'UPDATE sync_queue SET retry_count = retry_count + 1 WHERE id = ?',
[queueId],
);
}
void addConflict(
String entityType,
int entityId,
String operation,
Map<String, dynamic> local,
Map<String, dynamic> remote,
) {
_db.execute(
'''
INSERT INTO sync_conflicts
(entity_type, entity_id, operation, local_payload, remote_payload, created_at)
VALUES (?, ?, ?, ?, ?, ?)
''',
[
entityType,
entityId,
operation,
jsonEncode(local),
jsonEncode(remote),
DateTime.now().toUtc().toIso8601String(),
],
);
}
List<Map<String, dynamic>> conflicts() => _db
.select(
'SELECT * FROM sync_conflicts WHERE resolved_at IS NULL ORDER BY id DESC',
)
.map(
(row) => {
'id': (row['id'] as num).toInt(),
'entityType': row['entity_type'] as String,
'entityId': (row['entity_id'] as num).toInt(),
'operation': row['operation'] as String,
'local':
jsonDecode(row['local_payload'] as String)
as Map<String, dynamic>,
'remote':
jsonDecode(row['remote_payload'] as String)
as Map<String, dynamic>,
'createdAt': row['created_at'] as String,
},
)
.toList();
void resolveConflict(int id) {
_db.execute('UPDATE sync_conflicts SET resolved_at = ? WHERE id = ?', [
DateTime.now().toUtc().toIso8601String(),
id,
]);
}
Map<String, dynamic> createTransaction(Map<String, dynamic> value) {
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'] != _categoryType(value)) {
throw StateError('分类与收支类型不一致');
}
final id = _nextNegativeId('transactions');
final now = DateTime.now().toUtc().toIso8601String();
final occurredAt = (value['occurredAt'] as String?) ?? now;
_db.execute(
'''
INSERT INTO transactions
(id, ledger_id, category_id, type, amount, note, payment_method,
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,
value['ledgerId'] ?? 1,
categoryId,
value['type'],
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,
],
);
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 existed = _existingTransactionId(value) != null;
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(
'''
SELECT t.*, c.name AS category_name, c.icon_key AS category_icon,
c.color_key AS category_color
FROM transactions t JOIN categories c ON c.id = t.category_id
WHERE t.id = ? ${includeDeleted ? '' : 'AND t.is_deleted = 0'}
''',
[id],
);
return rows.isEmpty ? null : _transactionMap(rows.first);
}
Map<String, dynamic> updateTransaction(int id, Map<String, dynamic> value) {
final category = _db.select(
'SELECT type FROM categories WHERE id = ? AND is_deleted = 0',
[value['categoryId']],
);
if (category.isEmpty || category.first['type'] != _categoryType(value)) {
throw StateError('分类与收支类型不一致');
}
_db.execute(
'''
UPDATE transactions SET ledger_id = ?, category_id = ?, type = ?,
amount = ?, note = ?, payment_method = ?, transfer_direction = ?,
counterparty = ?, occurred_at = ?,
sync_state = 'local', updated_at = ? WHERE id = ?
''',
[
value['ledgerId'],
value['categoryId'],
value['type'],
value['amount'],
value['note'],
value['paymentMethod'],
value['transferDirection'],
value['counterparty'],
value['occurredAt'],
DateTime.now().toUtc().toIso8601String(),
id,
],
);
return transaction(id, includeDeleted: true)!;
}
void softDeleteTransaction(int id) {
final now = DateTime.now().toUtc().toIso8601String();
_db.execute(
'''
UPDATE transactions SET is_deleted = 1, deleted_at = ?,
sync_state = 'local', updated_at = ? WHERE id = ?
''',
[now, now, id],
);
}
Map<String, dynamic> restoreTransaction(int id) {
_db.execute(
'''
UPDATE transactions SET is_deleted = 0, deleted_at = NULL,
sync_state = 'local', updated_at = ? WHERE id = ?
''',
[DateTime.now().toUtc().toIso8601String(), id],
);
return transaction(id, includeDeleted: true)!;
}
void permanentlyDeleteTransaction(int id) {
_db.execute('DELETE FROM transactions WHERE id = ? AND is_deleted = 1', [
id,
]);
}
void clearRecycleBin(int ledgerId) {
_db.execute(
'DELETE FROM transactions WHERE ledger_id = ? AND is_deleted = 1',
[ledgerId],
);
}
List<Map<String, dynamic>> recycleBin(int ledgerId) => _db
.select(
'''
SELECT t.*, c.name AS category_name, c.icon_key AS category_icon,
c.color_key AS category_color
FROM transactions t JOIN categories c ON c.id = t.category_id
WHERE t.ledger_id = ? AND t.is_deleted = 1
ORDER BY t.deleted_at DESC, t.id DESC
''',
[ledgerId],
)
.map(_transactionMap)
.toList();
Map<String, dynamic> monthSummary(int year, int month, int ledgerId) {
final range = ShanghaiTime.monthRangeUtc(year, month);
final start = range.start;
final end = range.end;
final items = _transactionsBetween(ledgerId, start, end);
final groups = <DateTime, List<Map<String, dynamic>>>{};
for (final item in items) {
final occurredAt = ShanghaiTime.parseCivil(item['occurredAt'] as String);
final date = DateTime(occurredAt.year, occurredAt.month, occurredAt.day);
groups.putIfAbsent(date, () => []).add(item);
}
final days = groups.entries.toList()
..sort((a, b) => b.key.compareTo(a.key));
final income = _sumType(items, 'income');
final expense = _sumType(items, 'expense');
return {
'year': year,
'month': month,
'income': income,
'expense': expense,
'balance': income - expense,
'count': items.length,
'days': days
.map(
(entry) => {
'date': entry.key.toIso8601String(),
'expense': _sumType(entry.value, 'expense'),
'income': _sumType(entry.value, 'income'),
'items': entry.value,
},
)
.toList(),
};
}
Map<String, dynamic> periodStats(
String period,
DateTime anchor,
int ledgerId,
) {
late DateTime start;
late DateTime end;
late String label;
if (period == 'week') {
final offset = (anchor.weekday - DateTime.monday) % 7;
start = DateTime(
anchor.year,
anchor.month,
anchor.day,
).subtract(Duration(days: offset));
end = start.add(const Duration(days: 7));
label =
'${_monthDay(start)} - ${_monthDay(end.subtract(const Duration(days: 1)))}';
} else if (period == 'year') {
start = DateTime(anchor.year, 1, 1);
end = DateTime(anchor.year + 1, 1, 1);
label = '${anchor.year}年';
} else {
start = DateTime(anchor.year, anchor.month, 1);
end = DateTime(anchor.year, anchor.month + 1, 1);
label = '${anchor.year}${anchor.month}月';
}
final items = _transactionsBetween(
ledgerId,
ShanghaiTime.civilToUtc(start),
ShanghaiTime.civilToUtc(end),
);
final expenses = items.where(_isExpense).toList();
final expense = _sumType(items, 'expense');
final income = _sumType(items, 'income');
final categories = <int, Map<String, dynamic>>{};
for (final item in expenses) {
final categoryId = item['categoryId'] as int;
final aggregate = categories.putIfAbsent(
categoryId,
() => {
'categoryId': categoryId,
'name': item['categoryName'],
'iconKey': item['categoryIcon'],
'colorKey': item['categoryColor'],
'amount': 0.0,
},
);
aggregate['amount'] =
(aggregate['amount'] as double) + (item['amount'] as num).toDouble();
}
final byCategory =
categories.values.map((item) {
final amount = item['amount'] as double;
return {
...item,
'percent': expense == 0 ? 0.0 : amount / expense * 100,
};
}).toList()..sort(
(a, b) => (b['amount'] as double).compareTo(a['amount'] as double),
);
final trend = <Map<String, dynamic>>[];
if (period == 'year') {
for (var month = 1; month <= 12; month++) {
final pointItems = items
.where(
(item) =>
ShanghaiTime.parseCivil(item['occurredAt'] as String).month ==
month,
)
.toList();
trend.add({
'label': '$month月',
'date': DateTime(anchor.year, month, 1).toIso8601String(),
'expense': _sumType(pointItems, 'expense'),
'income': _sumType(pointItems, 'income'),
});
}
} else {
final dayCount = end.difference(start).inDays;
const weekLabels = ['周一', '周二', '周三', '周四', '周五', '周六', '周日'];
for (var index = 0; index < dayCount; index++) {
final date = start.add(Duration(days: index));
final pointItems = items.where((item) {
final local = ShanghaiTime.parseCivil(item['occurredAt'] as String);
return local.year == date.year &&
local.month == date.month &&
local.day == date.day;
}).toList();
trend.add({
'label': period == 'week' ? weekLabels[index] : '${date.day}日',
'date': date.toIso8601String(),
'expense': _sumType(pointItems, 'expense'),
'income': _sumType(pointItems, 'income'),
});
}
}
final top = byCategory.isEmpty ? null : byCategory.first;
return {
'periodType': period,
'periodLabel': label,
'startDate': start.toIso8601String(),
'endDate': end.toIso8601String(),
'totalExpense': expense,
'totalIncome': income,
'balance': income - expense,
'count': items.length,
'byCategory': byCategory,
'trend': trend,
'analysis': top == null
? '这个周期还没有支出记录'
: '${top['name']}支出最多,共 ¥${(top['amount'] as double).toStringAsFixed(0)}',
};
}
Map<String, dynamic> periodReport(
String period,
DateTime anchor,
int ledgerId,
) {
final stats = periodStats(period, anchor, ledgerId);
final start = DateTime.parse(stats['startDate'] as String);
final end = DateTime.parse(stats['endDate'] as String);
final items = _transactionsBetween(
ledgerId,
ShanghaiTime.civilToUtc(start),
ShanghaiTime.civilToUtc(end),
);
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);
final key = period == 'year'
? DateTime(local.year, local.month)
: DateTime(local.year, local.month, local.day);
grouped.putIfAbsent(key, () => []).add(item);
}
final peaks = grouped.entries.toList()
..sort(
(a, b) => _sumType(
b.value,
'expense',
).compareTo(_sumType(a.value, 'expense')),
);
final peak = peaks.isEmpty ? null : peaks.first;
final ranking = (stats['byCategory'] as List).map((item) {
final category = item as Map<String, dynamic>;
return {
'name': category['name'],
'iconKey': category['iconKey'],
'colorKey': category['colorKey'],
'amount': category['amount'],
'percent': category['percent'],
};
}).toList();
final top = ranking.isEmpty ? null : ranking.first;
const aiSources = {
'ai_chat',
'voice',
'ocr',
'screenshot',
'recognition_ai',
};
final aiCount = items
.where((item) => aiSources.contains(item['source']))
.length;
return {
'periodType': period == 'month' ? 'monthly' : '${period}ly',
'periodLabel': stats['periodLabel'],
'startDate': stats['startDate'],
'endDate': stats['endDate'],
'income': stats['totalIncome'],
'expense': stats['totalExpense'],
'balance': stats['balance'],
'count': stats['count'],
'aiRatio': items.isEmpty ? 0.0 : aiCount / items.length * 100,
'peakLabel': peak == null
? null
: period == 'year'
? '${peak.key.month}月'
: '${peak.key.month}${peak.key.day}日',
'peakAmount': peak == null ? 0.0 : _sumType(peak.value, 'expense'),
'peakNote': peak?.value.first['note'],
'topCategory': top?['name'],
'topCategoryAmount': top?['amount'] ?? 0.0,
'topCategoryPercent': top?['percent'] ?? 0.0,
'categoryRanking': ranking,
'commentary': stats['analysis'] ?? '本地统计已生成',
};
}
List<Map<String, dynamic>> searchTransactions({
required int ledgerId,
String? query,
bool aiOnly = false,
int? categoryId,
String? type,
double? minAmount,
double? maxAmount,
DateTime? from,
DateTime? to,
DateTime? beforeOccurredAt,
int? beforeId,
int limit = 50,
}) {
final values = _db
.select(
'''
SELECT t.*, c.name AS category_name, c.icon_key AS category_icon,
c.color_key AS category_color
FROM transactions t JOIN categories c ON c.id = t.category_id
WHERE t.ledger_id = ? AND t.is_deleted = 0
ORDER BY t.occurred_at DESC, t.id DESC
''',
[ledgerId],
)
.map(_transactionMap)
.where((item) {
final occurredAt = DateTime.parse(item['occurredAt'] as String);
final amount = (item['amount'] as num).toDouble();
final keyword = query?.trim().toLowerCase();
if (keyword != null && keyword.isNotEmpty) {
final haystack =
'${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) {
return false;
}
if (type != null && item['type'] != type) return false;
if (minAmount != null && amount < minAmount) return false;
if (maxAmount != null && amount > maxAmount) return false;
if (from != null &&
occurredAt.isBefore(ShanghaiTime.civilToUtc(from))) {
return false;
}
if (to != null && !occurredAt.isBefore(ShanghaiTime.civilToUtc(to))) {
return false;
}
if (beforeOccurredAt != null) {
final cursor = ShanghaiTime.civilToUtc(beforeOccurredAt);
if (occurredAt.isAfter(cursor)) return false;
if (occurredAt.isAtSameMomentAs(cursor) &&
beforeId != null &&
(item['id'] as int) >= beforeId) {
return false;
}
}
return true;
})
.take(limit)
.toList();
return values;
}
Map<String, dynamic> budgets(int year, int month, int ledgerId) {
final period = year * 100 + month;
final rows = _db.select(
'''
SELECT b.*, c.name AS category_name, c.icon_key AS category_icon,
c.color_key AS category_color
FROM budgets b LEFT JOIN categories c ON c.id = b.category_key
WHERE b.ledger_id = ? AND b.period IN (0, ?)
ORDER BY b.period DESC
''',
[ledgerId, period],
);
final chosen = <int, Row>{};
for (final row in rows) {
chosen.putIfAbsent((row['category_key'] as num).toInt(), () => row);
}
final monthItems = _transactionsBetween(
ledgerId,
ShanghaiTime.monthRangeUtc(year, month).start,
ShanghaiTime.monthRangeUtc(year, month).end,
);
Map<String, dynamic> mapBudget(Row row) {
final categoryKey = (row['category_key'] as num).toInt();
final spent = monthItems
.where(
(item) =>
_isExpense(item) &&
(categoryKey == 0 || item['categoryId'] == categoryKey),
)
.fold<double>(
0,
(sum, item) => sum + (item['amount'] as num).toDouble(),
);
return {
'categoryId': categoryKey == 0 ? null : categoryKey,
'categoryName': row['category_name'],
'categoryIcon': row['category_icon'],
'categoryColor': row['category_color'] ?? 'mint',
'amount': (row['amount'] as num).toDouble(),
'spent': spent,
'isRecurring': (row['is_recurring'] as num).toInt() == 1,
};
}
final total = chosen.remove(0);
final categoryRows = chosen.values.map(mapBudget).toList();
return {
'year': year,
'month': month,
'total': total == null ? null : mapBudget(total),
'categories': categoryRows,
};
}
void upsertBudget(
int year,
int month,
int ledgerId,
int? categoryId,
double amount,
bool recurring,
) {
final period = recurring ? 0 : year * 100 + month;
final categoryKey = categoryId ?? 0;
if (amount <= 0) {
_db.execute(
'DELETE FROM budgets WHERE ledger_id = ? AND period = ? AND category_key = ?',
[ledgerId, period, categoryKey],
);
return;
}
_db.execute(
'''
INSERT INTO budgets
(ledger_id, period, category_key, amount, is_recurring, updated_at)
VALUES (?, ?, ?, ?, ?, ?)
ON CONFLICT(ledger_id, period, category_key) DO UPDATE SET
amount = excluded.amount,
is_recurring = excluded.is_recurring,
updated_at = excluded.updated_at
''',
[
ledgerId,
period,
categoryKey,
amount,
recurring ? 1 : 0,
DateTime.now().toUtc().toIso8601String(),
],
);
}
void applyBudgets(
int year,
int month,
int ledgerId,
bool recurring,
Map<int?, double> values,
) {
_db.execute('BEGIN');
try {
for (final entry in values.entries) {
upsertBudget(year, month, ledgerId, entry.key, entry.value, recurring);
}
_db.execute('COMMIT');
} catch (_) {
_db.execute('ROLLBACK');
rethrow;
}
}
List<Map<String, dynamic>> _transactionsBetween(
int ledgerId,
DateTime start,
DateTime end,
) => _db
.select(
'''
SELECT t.*, c.name AS category_name, c.icon_key AS category_icon,
c.color_key AS category_color
FROM transactions t JOIN categories c ON c.id = t.category_id
WHERE t.ledger_id = ? AND t.is_deleted = 0
AND t.occurred_at >= ? AND t.occurred_at < ?
ORDER BY t.occurred_at DESC, t.id DESC
''',
[
start.toIso8601String(),
end.toIso8601String(),
].insertAtStart(ledgerId),
)
.map(_transactionMap)
.toList();
int _nextNegativeId(String table) {
final value =
_db.select('SELECT MIN(id) AS value FROM $table').first['value']
as num?;
final minimum = value?.toInt() ?? 0;
return minimum <= 0 ? minimum - 1 : -1;
}
Map<String, dynamic> _categoryMap(Row row) => {
'id': (row['id'] as num).toInt(),
'name': row['name'] as String,
'iconKey': row['icon_key'] as String,
'colorKey': row['color_key'] as String? ?? 'mint',
'type': row['type'] as String,
'sortOrder': (row['sort_order'] as num).toInt(),
'isCustom': (row['is_custom'] as num).toInt() == 1,
};
Map<String, dynamic> _transactionMap(Row row) => {
'id': (row['id'] as num).toInt(),
'ledgerId': (row['ledger_id'] as num).toInt(),
'categoryId': (row['category_id'] as num).toInt(),
'categoryName': row['category_name'] as String,
'categoryIcon': row['category_icon'] as String,
'categoryColor': row['category_color'] as String? ?? 'mint',
'type': row['type'] as String,
'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) => 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')}日';
}
extension _ParameterList on List<Object?> {
List<Object?> insertAtStart(Object? value) => [value, ...this];
}