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

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)}';
}
}