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 merge( Map snapshot, { String ledgerName = '游客数据', }) async { final ledgersResponse = await _dio.get('/api/ledgers'); final existingLedger = (ledgersResponse.data as List) .cast>() .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 = {}; final available = >[]; 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), ); } await _ensureFallbackCategory(available, 'expense'); await _ensureFallbackCategory(available, 'income'); for (final value in snapshot['categories'] as List? ?? const []) { final category = value as Map; if (category['isDeleted'] == true) continue; final existing = available.where( (item) => item['type'] == category['type'] && item['name'] == category['name'], ); Map 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; available.add(remote); } categoryMap[(category['id'] as num).toInt()] = (remote['id'] as num) .toInt(); } int mapCategory(Map 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? ?? const []) { final transaction = value as Map; 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? ?? const []) { final budget = value as Map; 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 _ensureFallbackCategory( List> 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); } 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), ); if (!hasFallback()) rethrow; } } static int? _findMappedDefault( List> available, Map snapshot, int oldCategoryId, ) { final transaction = (snapshot['transactions'] as List? ?? const []) .cast>() .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 transaction) { if (transaction['type'] != 'transfer') { return transaction['type'] as String; } return transaction['transferDirection'] == 'in' ? 'income' : 'expense'; } static String _importRequestId(Map 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)}'; } }