237 lines
8.1 KiB
Dart
237 lines
8.1 KiB
Dart
import 'dart:convert';
|
|
|
|
import 'package:crypto/crypto.dart';
|
|
import 'package:miaoji_zhang/shared/api/api_client.dart';
|
|
import 'package:miaoji_zhang/shared/services/current_ledger_store.dart';
|
|
import 'package:miaoji_zhang/shared/services/shanghai_time.dart';
|
|
|
|
class GuestMergeResult {
|
|
final int ledgerId;
|
|
final int transactionCount;
|
|
|
|
const GuestMergeResult({
|
|
required this.ledgerId,
|
|
required this.transactionCount,
|
|
});
|
|
}
|
|
|
|
class GuestMergeService {
|
|
GuestMergeService._();
|
|
|
|
static final _dio = ApiClient.instance.dio;
|
|
|
|
static Future<GuestMergeResult> merge(
|
|
Map<String, dynamic> snapshot, {
|
|
String ledgerName = '游客数据',
|
|
}) async {
|
|
final ledgersResponse = await _dio.get('/api/ledgers');
|
|
final existingLedger = (ledgersResponse.data as List<dynamic>)
|
|
.cast<Map<String, dynamic>>()
|
|
.where((item) => item['name'] == ledgerName)
|
|
.firstOrNull;
|
|
final ledgerId = existingLedger == null
|
|
? ((await _dio.post(
|
|
'/api/ledgers',
|
|
data: {'name': ledgerName, 'iconKey': 'wallet'},
|
|
)).data['id']
|
|
as num)
|
|
.toInt()
|
|
: (existingLedger['id'] as num).toInt();
|
|
final categoryMap = <int, int>{};
|
|
final available = <Map<String, dynamic>>[];
|
|
for (final type in ['expense', 'income']) {
|
|
final response = await _dio.get(
|
|
'/api/categories',
|
|
queryParameters: {'type': type},
|
|
);
|
|
available.addAll(
|
|
(response.data as List).map((item) => item as Map<String, dynamic>),
|
|
);
|
|
}
|
|
await _ensureFallbackCategory(available, 'expense');
|
|
await _ensureFallbackCategory(available, 'income');
|
|
|
|
for (final value in snapshot['categories'] as List<dynamic>? ?? const []) {
|
|
final category = value as Map<String, dynamic>;
|
|
if (category['isDeleted'] == true) continue;
|
|
final existing = available.where(
|
|
(item) =>
|
|
item['type'] == category['type'] &&
|
|
item['name'] == category['name'],
|
|
);
|
|
Map<String, dynamic> remote;
|
|
if (existing.isNotEmpty) {
|
|
remote = existing.first;
|
|
} else if (category['isCustom'] == false) {
|
|
continue;
|
|
} else {
|
|
final response = await _dio.post(
|
|
'/api/categories',
|
|
data: {
|
|
'name': category['name'],
|
|
'iconKey': category['iconKey'],
|
|
'colorKey': category['colorKey'],
|
|
'type': category['type'],
|
|
},
|
|
);
|
|
remote = response.data as Map<String, dynamic>;
|
|
available.add(remote);
|
|
}
|
|
categoryMap[(category['id'] as num).toInt()] = (remote['id'] as num)
|
|
.toInt();
|
|
}
|
|
|
|
int mapCategory(Map<String, dynamic> transaction) {
|
|
final oldId = (transaction['categoryId'] as num).toInt();
|
|
final custom = categoryMap[oldId];
|
|
if (custom != null) return custom;
|
|
final categoryType = _transactionCategoryType(transaction);
|
|
final exact = available.where(
|
|
(item) =>
|
|
item['type'] == categoryType &&
|
|
item['name'] == transaction['categoryName'],
|
|
);
|
|
if (exact.isNotEmpty) return (exact.first['id'] as num).toInt();
|
|
final fallback = available.firstWhere(
|
|
(item) => item['type'] == categoryType && item['name'] == '其他',
|
|
orElse: () => throw StateError('$categoryType 分类缺少“其他”,无法导入本机账单'),
|
|
);
|
|
return (fallback['id'] as num).toInt();
|
|
}
|
|
|
|
var transactionCount = 0;
|
|
for (final value
|
|
in snapshot['transactions'] as List<dynamic>? ?? const []) {
|
|
final transaction = value as Map<String, dynamic>;
|
|
if (transaction['isDeleted'] == true) continue;
|
|
await _dio.post(
|
|
'/api/transactions',
|
|
data: {
|
|
'ledgerId': ledgerId,
|
|
'categoryId': mapCategory(transaction),
|
|
'type': transaction['type'],
|
|
'amount': transaction['amount'],
|
|
'note': transaction['note'],
|
|
'paymentMethod': transaction['paymentMethod'],
|
|
'transferDirection': transaction['transferDirection'],
|
|
'counterparty': transaction['counterparty'],
|
|
'occurredAt': transaction['occurredAt'],
|
|
'source': 'manual',
|
|
'sourceText': null,
|
|
'clientRequestId': _importRequestId(transaction),
|
|
},
|
|
);
|
|
transactionCount++;
|
|
}
|
|
|
|
for (final value in snapshot['budgets'] as List<dynamic>? ?? const []) {
|
|
final budget = value as Map<String, dynamic>;
|
|
final period = (budget['period'] as num).toInt();
|
|
final recurring = budget['recurring'] == true || period == 0;
|
|
final current = ShanghaiTime.now;
|
|
final year = period == 0 ? current.year : period ~/ 100;
|
|
final month = period == 0 ? current.month : period % 100;
|
|
final oldCategoryId = (budget['categoryId'] as num?)?.toInt();
|
|
await _dio.put(
|
|
'/api/budgets',
|
|
queryParameters: {'year': year, 'month': month, 'ledgerId': ledgerId},
|
|
data: {
|
|
'categoryId': oldCategoryId == null
|
|
? null
|
|
: categoryMap[oldCategoryId] ??
|
|
_findMappedDefault(available, snapshot, oldCategoryId),
|
|
'amount': budget['amount'],
|
|
'recurring': recurring,
|
|
},
|
|
);
|
|
}
|
|
|
|
await CurrentLedgerStore.instance.ensureLoaded(force: true);
|
|
await CurrentLedgerStore.instance.select(ledgerId);
|
|
return GuestMergeResult(
|
|
ledgerId: ledgerId,
|
|
transactionCount: transactionCount,
|
|
);
|
|
}
|
|
|
|
static Future<void> _ensureFallbackCategory(
|
|
List<Map<String, dynamic>> available,
|
|
String type,
|
|
) async {
|
|
bool hasFallback() => available.any(
|
|
(item) => item['type'] == type && item['name'] == '其他',
|
|
);
|
|
if (hasFallback()) return;
|
|
|
|
try {
|
|
final response = await _dio.post(
|
|
'/api/categories',
|
|
data: {
|
|
'name': '其他',
|
|
'iconKey': 'tag',
|
|
'colorKey': type == 'income' ? 'lime' : 'graphite',
|
|
'type': type,
|
|
},
|
|
);
|
|
available.add(response.data as Map<String, dynamic>);
|
|
} catch (_) {
|
|
// Another request may have created it between the list and create calls.
|
|
final response = await _dio.get(
|
|
'/api/categories',
|
|
queryParameters: {'type': type},
|
|
);
|
|
available.removeWhere((item) => item['type'] == type);
|
|
available.addAll(
|
|
(response.data as List).map((item) => item as Map<String, dynamic>),
|
|
);
|
|
if (!hasFallback()) rethrow;
|
|
}
|
|
}
|
|
|
|
static int? _findMappedDefault(
|
|
List<Map<String, dynamic>> available,
|
|
Map<String, dynamic> snapshot,
|
|
int oldCategoryId,
|
|
) {
|
|
final transaction = (snapshot['transactions'] as List<dynamic>? ?? const [])
|
|
.cast<Map<String, dynamic>>()
|
|
.where(
|
|
(item) =>
|
|
item['categoryId'] == oldCategoryId && item['isDeleted'] != true,
|
|
)
|
|
.firstOrNull;
|
|
if (transaction == null) return null;
|
|
final match = available.where(
|
|
(item) =>
|
|
item['type'] == _transactionCategoryType(transaction) &&
|
|
item['name'] == transaction['categoryName'],
|
|
);
|
|
return match.isEmpty ? null : (match.first['id'] as num).toInt();
|
|
}
|
|
|
|
static String _transactionCategoryType(Map<String, dynamic> transaction) {
|
|
if (transaction['type'] != 'transfer') {
|
|
return transaction['type'] as String;
|
|
}
|
|
return transaction['transferDirection'] == 'in' ? 'income' : 'expense';
|
|
}
|
|
|
|
static String _importRequestId(Map<String, dynamic> transaction) {
|
|
final existing = transaction['clientRequestId']?.toString().trim();
|
|
if (existing != null && existing.isNotEmpty && existing.length <= 64) {
|
|
return existing;
|
|
}
|
|
final identity = jsonEncode([
|
|
transaction['id'],
|
|
transaction['ledgerId'],
|
|
transaction['categoryId'],
|
|
transaction['type'],
|
|
transaction['transferDirection'],
|
|
transaction['amount'],
|
|
transaction['occurredAt'],
|
|
transaction['note'],
|
|
]);
|
|
return 'local-import-${sha256.convert(utf8.encode(identity)).toString().substring(0, 48)}';
|
|
}
|
|
}
|