Files
submanager/lib/services/sms_scanner.dart
2025-11-14 16:53:41 +09:00

739 lines
24 KiB
Dart

import 'dart:math' as math;
import 'package:flutter/foundation.dart' show kIsWeb, compute;
import 'package:flutter_sms_inbox/flutter_sms_inbox.dart';
import '../models/subscription_model.dart';
import '../utils/logger.dart';
import '../temp/test_sms_data.dart';
import '../services/subscription_url_matcher.dart';
import '../utils/platform_helper.dart';
import '../utils/business_day_util.dart';
import '../services/sms_scan/sms_scan_result.dart';
import '../models/payment_card_suggestion.dart';
class SmsScanner {
final SmsQuery _query = SmsQuery();
Future<List<SmsScanResult>> scanForSubscriptions() async {
try {
List<dynamic> smsList;
Log.d('SmsScanner: 스캔 시작');
// 플랫폼별 분기 처리
if (kIsWeb) {
// 웹 환경: 테스트 데이터 사용
Log.i('SmsScanner: 웹 환경에서 테스트 데이터 사용');
smsList = TestSmsData.getTestData();
Log.d('SmsScanner: 테스트 데이터 개수: ${smsList.length}');
} else if (PlatformHelper.isIOS) {
// iOS: SMS 접근 불가, 빈 리스트 반환
Log.w('SmsScanner: iOS에서는 SMS 스캔 불가');
return [];
} else if (PlatformHelper.isAndroid) {
// Android: flutter_sms_inbox 사용
Log.i('SmsScanner: Android에서 실제 SMS 스캔');
smsList = await _scanAndroidSms();
Log.d('SmsScanner: 스캔된 SMS 개수: ${smsList.length}');
} else {
// 기타 플랫폼
Log.w('SmsScanner: 지원하지 않는 플랫폼');
return [];
}
// SMS 데이터를 분석하여 반복 결제되는 구독 식별
final List<SmsScanResult> subscriptions = [];
final serviceGroups = _groupMessagesByIdentifier(smsList);
Log.d('SmsScanner: 그룹화된 키 수: ${serviceGroups.length}');
for (final entry in serviceGroups.entries) {
Log.d('SmsScanner: 그룹 "${entry.key}" - 메시지 ${entry.value.length}');
final repeatResult = _detectRepeatingSubscriptions(entry.value);
if (repeatResult == null) {
Log.d('SmsScanner: 반복 조건 불충족 - ${entry.key}');
continue;
}
final result =
_parseSms(repeatResult.baseMessage, repeatResult.repeatCount);
if (result != null) {
Log.i(
'SmsScanner: 구독 추가: ${result.model.serviceName}, 반복 횟수: ${result.model.repeatCount}');
subscriptions.add(result);
} else {
Log.w('SmsScanner: 구독 파싱 실패: ${entry.key}');
}
}
Log.d('SmsScanner: 최종 구독 개수: ${subscriptions.length}');
return subscriptions;
} catch (e) {
Log.e('SmsScanner: 예외 발생', e);
throw Exception('SMS 스캔 중 오류 발생: $e');
}
}
// Android에서 flutter_sms_inbox를 사용한 SMS 스캔
Future<List<dynamic>> _scanAndroidSms() async {
try {
final messages = await _query.getAllSms;
// Isolate로 넘길 수 있도록 직렬화 (plugin 객체 직접 전달 불가)
final serialized = <Map<String, dynamic>>[];
for (final message in messages) {
serialized.add({
'body': message.body ?? '',
'address': message.address ?? '',
'dateMillis': (message.date ?? DateTime.now()).millisecondsSinceEpoch,
});
}
// 대량 파싱은 별도 Isolate로 오프로딩
final List<Map<String, dynamic>> smsList =
await compute(_parseRawSmsBatch, serialized);
return smsList;
} catch (e) {
Log.e('SmsScanner: Android SMS 스캔 실패', e);
return [];
}
}
// (사용되지 않음) 기존 단일 메시지 파서 제거됨 - Isolate 배치 파싱으로 대체
SmsScanResult? _parseSms(Map<String, dynamic> sms, int repeatCount) {
try {
final serviceName = sms['serviceName'] as String? ?? '알 수 없는 서비스';
final monthlyCost = (sms['monthlyCost'] as num?)?.toDouble() ?? 0.0;
final billingCycle = SubscriptionModel.normalizeBillingCycle(
sms['billingCycle'] as String? ?? 'monthly');
final nextBillingDateStr = sms['nextBillingDate'] as String?;
// 실제 반복 횟수 사용 (테스트 데이터에서는 이미 제공됨)
final actualRepeatCount = repeatCount > 0 ? repeatCount : 1;
final message = sms['message'] as String? ?? '';
// 통화 단위 감지 - 메시지 내용과 서비스명 모두 검사
String currency = _detectCurrency(message);
// 서비스명에 따라 통화 단위 재확인
final dollarServices = [
'GitHub',
'GitHub Pro',
'Netflix US',
'Spotify',
'Spotify Premium'
];
if (dollarServices.any((service) => serviceName.contains(service))) {
Log.d('서비스명 $serviceName으로 USD 통화 단위 확정');
currency = 'USD';
}
DateTime? nextBillingDate;
// 외부에서 계산된 다음 결제일이 있으면 우선 사용
final overrideNext = sms['overrideNextBillingDate'] as String?;
if (overrideNext != null) {
nextBillingDate = DateTime.tryParse(overrideNext);
} else if (nextBillingDateStr != null) {
nextBillingDate = DateTime.tryParse(nextBillingDateStr);
}
DateTime? lastPaymentDate;
final previousPaymentDateStr = sms['previousPaymentDate'] as String?;
if (previousPaymentDateStr != null && previousPaymentDateStr.isNotEmpty) {
lastPaymentDate = DateTime.tryParse(previousPaymentDateStr);
}
// 결제일 계산 로직 추가 - 미래 날짜가 아니면 조정
DateTime adjustedNextBillingDate = _calculateNextBillingDate(
nextBillingDate ?? DateTime.now().add(const Duration(days: 30)),
billingCycle,
);
// 주말/공휴일 보정
adjustedNextBillingDate =
BusinessDayUtil.nextBusinessDay(adjustedNextBillingDate);
final model = SubscriptionModel(
id: DateTime.now().millisecondsSinceEpoch.toString(),
serviceName: serviceName,
monthlyCost: monthlyCost,
billingCycle: billingCycle,
nextBillingDate: adjustedNextBillingDate,
isAutoDetected: true,
repeatCount: actualRepeatCount,
lastPaymentDate: lastPaymentDate,
websiteUrl: _extractWebsiteUrl(serviceName),
currency: currency, // 통화 단위 설정
);
final suggestion = _extractPaymentCardSuggestion(message);
return SmsScanResult(
model: model,
cardSuggestion: suggestion,
rawMessage: message,
);
} catch (e) {
return null;
}
}
PaymentCardSuggestion? _extractPaymentCardSuggestion(String message) {
if (message.isEmpty) return null;
final issuer = _detectCardIssuer(message);
final last4 = _detectCardLast4(message);
if (issuer == null && last4 == null) {
return null;
}
return PaymentCardSuggestion(
issuerName: issuer ?? '결제수단',
last4: last4,
source: 'sms',
);
}
String? _detectCardIssuer(String message) {
final normalized = message.toLowerCase();
const issuerKeywords = {
'KB국민카드': ['kb국민', '국민카드', 'kb card', 'kookmin'],
'신한카드': ['신한', 'shinhan'],
'우리카드': ['우리카드', 'woori'],
'하나카드': ['하나카드', 'hana card', 'hana'],
'농협카드': ['농협', 'nh', '농협카드'],
'BC카드': ['bc카드', 'bc card'],
'삼성카드': ['삼성카드', 'samsung card'],
'롯데카드': ['롯데카드', 'lotte card'],
'현대카드': ['현대카드', 'hyundai card'],
'씨티카드': ['씨티카드', 'citi card', 'citibank'],
'카카오뱅크': ['카카오뱅크', 'kakaobank'],
'토스뱅크': ['토스뱅크', 'toss bank'],
'Visa': ['visa'],
'Mastercard': ['mastercard', 'master card'],
'American Express': ['amex', 'american express'],
};
for (final entry in issuerKeywords.entries) {
final match = entry.value.any((keyword) => normalized.contains(keyword));
if (match) {
return entry.key;
}
}
return null;
}
String? _detectCardLast4(String message) {
final patterns = [
RegExp(r'\*{3,}\s*(\d{4})'),
RegExp(r'끝번호\s*(\d{4})'),
RegExp(r'마지막\s*(\d{4})'),
RegExp(r'\((\d{4})\)'),
RegExp(r'ending(?: in)?\s*(\d{4})', caseSensitive: false),
];
for (final pattern in patterns) {
final match = pattern.firstMatch(message);
if (match != null && match.groupCount >= 1) {
final candidate = match.group(1);
if (candidate != null && candidate.length == 4) {
return candidate;
}
}
}
return null;
}
// 다음 결제일 계산 (현재 날짜 기준으로 조정)
DateTime _calculateNextBillingDate(
DateTime billingDate, String billingCycle) {
final now = DateTime.now();
// 결제일이 이미 미래인 경우 그대로 반환
if (billingDate.isAfter(now)) {
return billingDate;
}
// 결제 주기별 다음 결제일 계산
if (billingCycle == 'monthly') {
int month = now.month;
int year = now.year;
// 현재 달의 결제일이 이미 지났으면 다음 달로 이동
if (now.day >= billingDate.day) {
month = month + 1;
if (month > 12) {
month = 1;
year = year + 1;
}
}
final dim = BusinessDayUtil.daysInMonth(year, month);
final day = billingDate.day.clamp(1, dim);
return DateTime(year, month, day);
} else if (billingCycle == 'yearly') {
// 올해의 결제일이 지났는지 확인
final thisYearBilling =
DateTime(now.year, billingDate.month, billingDate.day);
if (thisYearBilling.isBefore(now)) {
return DateTime(now.year + 1, billingDate.month, billingDate.day);
} else {
return thisYearBilling;
}
} else if (billingCycle == 'weekly') {
// 가장 가까운 다음 주 같은 요일 계산
final dayDifference = billingDate.weekday - now.weekday;
final daysToAdd = dayDifference > 0 ? dayDifference : 7 + dayDifference;
return now.add(Duration(days: daysToAdd));
}
// 기본 처리: 30일 후
return now.add(const Duration(days: 30));
}
String? _extractWebsiteUrl(String serviceName) {
// SubscriptionUrlMatcher 서비스를 사용하여 URL 매칭 시도
final suggestedUrl = SubscriptionUrlMatcher.findMatchingUrl(serviceName);
// 매칭된 URL이 있으면 반환, 없으면 기존 매핑 사용
if (suggestedUrl != null && suggestedUrl.isNotEmpty) {
return suggestedUrl;
}
// 기존 하드코딩된 매핑 (필요한 경우 폴백으로 사용)
final Map<String, String> serviceUrls = {
'넷플릭스': 'https://www.netflix.com',
'디즈니플러스': 'https://www.disneyplus.com',
'유튜브프리미엄': 'https://www.youtube.com',
'YouTube Premium': 'https://www.youtube.com',
'애플 iCloud': 'https://www.icloud.com',
'Microsoft 365': 'https://www.microsoft.com/microsoft-365',
'멜론': 'https://www.melon.com',
'웨이브': 'https://www.wavve.com',
'Apple Music': 'https://www.apple.com/apple-music',
'Netflix': 'https://www.netflix.com',
'Disney+': 'https://www.disneyplus.com',
'Spotify': 'https://www.spotify.com',
};
return serviceUrls[serviceName];
}
// 메시지에서 통화 단위를 감지하는 함수
String _detectCurrency(String message) {
final dollarKeywords = [
'\$', 'USD', 'dollar', '달러', 'dollars', 'US\$',
// 해외 서비스 이름
'Netflix US', 'Spotify Premium', 'Apple US', 'Amazon US', 'GitHub'
];
// 특정 서비스명으로 통화 단위 결정
final Map<String, String> serviceCurrencyMap = {
'Netflix US': 'USD',
'Spotify Premium': 'USD',
'Spotify': 'USD',
'GitHub': 'USD',
'GitHub Pro': 'USD',
};
// 서비스명 기반 통화 단위 확인
for (final service in serviceCurrencyMap.keys) {
if (message.contains(service)) {
Log.d('_detectCurrency: $service는 USD 서비스로 판별됨');
return 'USD';
}
}
// 메시지에 달러 관련 키워드가 있는지 확인
for (final keyword in dollarKeywords) {
if (message.toLowerCase().contains(keyword.toLowerCase())) {
Log.d('_detectCurrency: USD 키워드 발견: $keyword');
return 'USD';
}
}
// 기본값은 원화
return 'KRW';
}
}
// ===== Isolate 오프로딩용 Top-level 파서 =====
// 대량 SMS 파싱: plugin 객체를 직렬화한 맵 리스트를 받아 구독 후보 맵 리스트로 변환
List<Map<String, dynamic>> _parseRawSmsBatch(
List<Map<String, dynamic>> messages) {
final amountPatterns = <RegExp>[
RegExp(r'(\d{1,3}(?:,\d{3})*)(?:원|₩)'), // 원화
RegExp(r'\$(\d+(?:\.\d{2})?)'), // 달러
RegExp(r'(\d+(?:\.\d{2})?)\s*USD'), // USD
RegExp(r'결제.*?(\d{1,3}(?:,\d{3})*)'), // 결제 금액
];
final results = <Map<String, dynamic>>[];
for (final m in messages) {
final body = (m['body'] as String?) ?? '';
final sender = (m['address'] as String?) ?? '';
final dateMillis =
(m['dateMillis'] as int?) ?? DateTime.now().millisecondsSinceEpoch;
final date = DateTime.fromMillisecondsSinceEpoch(dateMillis);
final serviceName = _isoExtractServiceName(body, sender);
final amount = _isoExtractAmount(body, amountPatterns);
final billingCycle = _isoExtractBillingCycle(body);
final nextBillingDate =
_isoCalculateNextBillingFromDate(date, billingCycle);
final normalizedBody = _isoNormalizeBody(body);
results.add({
'serviceName': serviceName,
'address': sender,
'monthlyCost': amount,
'billingCycle': billingCycle,
'message': body,
'normalizedBody': normalizedBody,
'nextBillingDate': nextBillingDate.toIso8601String(),
'previousPaymentDate': date.toIso8601String(),
});
}
return results;
}
String _isoExtractServiceName(String body, String sender) {
final servicePatterns = {
'netflix': '넷플릭스',
'youtube': '유튜브 프리미엄',
'spotify': 'Spotify',
'disney': '디즈니플러스',
'apple': 'Apple',
'microsoft': 'Microsoft',
'github': 'GitHub',
'adobe': 'Adobe',
'멜론': '멜론',
'웨이브': '웨이브',
};
final combined = '$body $sender'.toLowerCase();
for (final e in servicePatterns.entries) {
if (combined.contains(e.key)) return e.value;
}
return _isoExtractServiceNameFromSender(sender);
}
String _isoExtractServiceNameFromSender(String sender) {
if (RegExp(r'^\d+$').hasMatch(sender)) {
return '알 수 없는 서비스';
}
return sender.replaceAll(RegExp(r'[^\w\s가-힣]'), '').trim();
}
double? _isoExtractAmount(String body, List<RegExp> patterns) {
for (final pattern in patterns) {
final match = pattern.firstMatch(body);
if (match != null) {
var amountStr = match.group(1) ?? '';
amountStr = amountStr.replaceAll(',', '');
final parsed = double.tryParse(amountStr);
if (parsed != null) return parsed;
}
}
return null;
}
String _isoExtractBillingCycle(String body) {
if (body.contains('') ||
body.toLowerCase().contains('monthly') ||
body.contains('매월')) {
return 'monthly';
} else if (body.contains('') ||
body.toLowerCase().contains('yearly') ||
body.toLowerCase().contains('annual')) {
return 'yearly';
} else if (body.contains('') || body.toLowerCase().contains('weekly')) {
return 'weekly';
}
return 'monthly';
}
String _isoNormalizeBody(String body) {
final patterns = <RegExp>[
RegExp(r'\d{4}[./-]\d{1,2}[./-]\d{1,2}'),
RegExp(r'\d{1,2}[./-]\d{1,2}[./-]\d{2,4}'),
RegExp(r'\d{4}\s*년\s*\d{1,2}\s*월\s*\d{1,2}\s*일'),
RegExp(r'\d{1,2}\s*월\s*\d{1,2}\s*일'),
RegExp(r'\d{1,2}:\d{2}'),
];
var normalized = body;
for (final pattern in patterns) {
normalized = normalized.replaceAll(pattern, ' ');
}
return normalized.replaceAll(RegExp(r'\s+'), ' ').trim().toLowerCase();
}
DateTime _isoCalculateNextBillingFromDate(
DateTime lastDate, String billingCycle) {
switch (billingCycle) {
case 'monthly':
return DateTime(lastDate.year, lastDate.month + 1, lastDate.day);
case 'yearly':
return DateTime(lastDate.year + 1, lastDate.month, lastDate.day);
case 'weekly':
return lastDate.add(const Duration(days: 7));
default:
return lastDate.add(const Duration(days: 30));
}
}
Map<String, List<Map<String, dynamic>>> _groupMessagesByIdentifier(
List<dynamic> smsList) {
final Map<String, List<Map<String, dynamic>>> groups = {};
for (final smsEntry in smsList) {
if (smsEntry is! Map) continue;
final sms = Map<String, dynamic>.from(smsEntry as Map<String, dynamic>);
final serviceName = (sms['serviceName'] as String?)?.trim();
final address = (sms['address'] as String?)?.trim();
final sender = (sms['sender'] as String?)?.trim();
String key = (serviceName != null &&
serviceName.isNotEmpty &&
serviceName != '알 수 없는 서비스')
? serviceName
: (address?.isNotEmpty == true
? address!
: (sender?.isNotEmpty == true ? sender! : 'unknown'));
groups.putIfAbsent(key, () => []).add(sms);
}
return groups;
}
class _RepeatDetectionResult {
_RepeatDetectionResult({
required this.baseMessage,
required this.repeatCount,
});
final Map<String, dynamic> baseMessage;
final int repeatCount;
}
enum _MatchType { none, monthly, yearly, identical }
class _MatchedPair {
_MatchedPair(this.first, this.second, this.type);
final int first;
final int second;
final _MatchType type;
}
_RepeatDetectionResult? _detectRepeatingSubscriptions(
List<Map<String, dynamic>> messages) {
if (messages.length < 2) return null;
final sorted = messages.map((sms) => Map<String, dynamic>.from(sms)).toList()
..sort((a, b) {
final da = _parsePaymentDate(a['previousPaymentDate']);
final db = _parsePaymentDate(b['previousPaymentDate']);
return (db ?? DateTime.fromMillisecondsSinceEpoch(0))
.compareTo(da ?? DateTime.fromMillisecondsSinceEpoch(0));
});
final matchedIndices = <int>{};
final matchedPairs = <_MatchedPair>[];
for (int i = 0; i < sorted.length - 1; i++) {
for (int j = i + 1; j < sorted.length && j <= i + 5; j++) {
final matchType = _evaluateMatch(sorted[i], sorted[j]);
if (matchType == _MatchType.none) continue;
matchedIndices.add(i);
matchedIndices.add(j);
matchedPairs.add(_MatchedPair(i, j, matchType));
break;
}
}
if (matchedIndices.length < 2) return null;
final baseIndex = matchedIndices
.reduce((value, element) => value < element ? value : element);
final baseMessage = Map<String, dynamic>.from(sorted[baseIndex]);
final overrideDate = _deriveNextBillingDate(sorted, matchedPairs);
if (overrideDate != null) {
baseMessage['overrideNextBillingDate'] = overrideDate.toIso8601String();
}
return _RepeatDetectionResult(
baseMessage: baseMessage,
repeatCount: matchedIndices.length,
);
}
_MatchType _evaluateMatch(
Map<String, dynamic> recent, Map<String, dynamic> previous) {
final amountMatch = _matchByAmountAndInterval(recent, previous);
if (amountMatch != _MatchType.none) {
return amountMatch;
}
if (_areBodiesEquivalent(recent, previous)) {
final inferredInterval = _classifyIntervalByDates(recent, previous);
return inferredInterval == _MatchType.none
? _MatchType.identical
: inferredInterval;
}
return _MatchType.none;
}
_MatchType _matchByAmountAndInterval(
Map<String, dynamic> a, Map<String, dynamic> b) {
final amountA = (a['monthlyCost'] as num?)?.toDouble();
final amountB = (b['monthlyCost'] as num?)?.toDouble();
if (amountA == null || amountB == null) return _MatchType.none;
if (!_isAmountSimilar(amountA, amountB)) return _MatchType.none;
return _classifyIntervalByDates(a, b);
}
_MatchType _classifyIntervalByDates(
Map<String, dynamic> a, Map<String, dynamic> b) {
final dateA = _parsePaymentDate(a['previousPaymentDate']);
final dateB = _parsePaymentDate(b['previousPaymentDate']);
if (dateA == null || dateB == null) return _MatchType.none;
final diffDays = (dateA.difference(dateB).inDays).abs();
if (diffDays >= 27 && diffDays <= 34) {
return _MatchType.monthly;
}
if (diffDays >= 350 && diffDays <= 380) {
return _MatchType.yearly;
}
return _MatchType.none;
}
bool _areBodiesEquivalent(Map<String, dynamic> a, Map<String, dynamic> b) {
final normalizedA = _getNormalizedBody(a);
final normalizedB = _getNormalizedBody(b);
if (normalizedA.isEmpty || normalizedB.isEmpty) return false;
return normalizedA == normalizedB;
}
String _getNormalizedBody(Map<String, dynamic> sms) {
final cached = sms['normalizedBody'] as String?;
if (cached != null && cached.isNotEmpty) return cached;
final message = sms['message'] as String? ?? '';
final normalized = _isoNormalizeBody(message);
sms['normalizedBody'] = normalized;
return normalized;
}
DateTime? _deriveNextBillingDate(
List<Map<String, dynamic>> sorted, List<_MatchedPair> pairs) {
if (pairs.isEmpty) return null;
final targetPair = pairs.firstWhere(
(pair) => pair.type == _MatchType.monthly || pair.type == _MatchType.yearly,
orElse: () => pairs.first,
);
final recent = sorted[targetPair.first];
final previous = sorted[targetPair.second];
final recentDate = _parsePaymentDate(recent['previousPaymentDate']);
final prevDate = _parsePaymentDate(previous['previousPaymentDate']);
return _calculateNextBillingFromPair(recentDate, prevDate, targetPair.type);
}
DateTime? _calculateNextBillingFromPair(
DateTime? recentDate, DateTime? prevDate, _MatchType type) {
if (recentDate == null) return null;
if (type == _MatchType.monthly) {
DateTime candidate = _addMonths(recentDate, 1);
while (!candidate.isAfter(DateTime.now())) {
candidate = _addMonths(candidate, 1);
}
return BusinessDayUtil.nextBusinessDay(candidate);
}
if (type == _MatchType.yearly) {
DateTime candidate = DateTime(
recentDate.year + 1,
recentDate.month,
_clampDay(
recentDate.day,
BusinessDayUtil.daysInMonth(recentDate.year + 1, recentDate.month),
),
);
while (!candidate.isAfter(DateTime.now())) {
candidate = DateTime(candidate.year + 1, candidate.month, candidate.day);
}
return BusinessDayUtil.nextBusinessDay(candidate);
}
return _inferMonthlyNextBilling(recentDate, prevDate);
}
DateTime? _inferMonthlyNextBilling(DateTime recentDate, DateTime? prevDate) {
int baseDay = recentDate.day;
if (prevDate != null) {
final candidate = DateTime(prevDate.year, prevDate.month, baseDay);
if (BusinessDayUtil.isWeekend(candidate)) {
final diff = prevDate.difference(candidate).inDays;
if (diff < 1 || diff > 3) {
baseDay = prevDate.day;
}
}
}
final now = DateTime.now();
int year = now.year;
int month = now.month;
if (now.day >= baseDay) {
month += 1;
if (month > 12) {
month = 1;
year += 1;
}
}
final dim = BusinessDayUtil.daysInMonth(year, month);
final day = _clampDay(baseDay, dim);
var nextBilling = DateTime(year, month, day);
return BusinessDayUtil.nextBusinessDay(nextBilling);
}
DateTime _addMonths(DateTime date, int months) {
final totalMonths = (date.month - 1) + months;
final year = date.year + totalMonths ~/ 12;
final month = totalMonths % 12 + 1;
final dim = BusinessDayUtil.daysInMonth(year, month);
final day = _clampDay(date.day, dim);
return DateTime(year, month, day);
}
int _clampDay(int day, int maxDay) {
if (day < 1) return 1;
if (day > maxDay) return maxDay;
return day;
}
DateTime? _parsePaymentDate(dynamic value) {
if (value is DateTime) return value;
if (value is String && value.isNotEmpty) {
return DateTime.tryParse(value);
}
return null;
}
bool _isAmountSimilar(double a, double b) {
final diff = (a - b).abs();
final base = math.max(a.abs(), b.abs());
final tolerance = base * 0.01; // 1% 허용
final minTolerance = base < 10 ? 0.1 : 1.0;
return diff <= math.max(tolerance, minTolerance);
}