1694 lines
56 KiB
Dart
1694 lines
56 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() {
|
|
final database = LocalDatabase._();
|
|
database._database = sqlite3.openInMemory();
|
|
database._namespace = 'test';
|
|
database._migrate();
|
|
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();
|
|
} 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();
|
|
}
|
|
}
|
|
|
|
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];
|
|
}
|