ponshu-room-lite/lib/services/sakenowa_auto_matching_serv...

237 lines
7.4 KiB
Dart
Raw Normal View History

import 'package:flutter/foundation.dart';
import '../models/sake_item.dart';
import '../models/sakenowa/sakenowa_models.dart';
import '../utils/string_similarity.dart';
import 'sakenowa_service.dart';
/// マッチング結果
class MatchResult {
final SakenowaBrand? brand;
final SakenowaBrewery? brewery;
final SakenowaArea? area;
final SakenowaFlavorChart? flavorChart;
final double score; // 0.0-1.0
final bool isConfident; // スコア >= 0.8
MatchResult({
this.brand,
this.brewery,
this.area,
this.flavorChart,
required this.score,
required this.isConfident,
});
bool get hasMatch => brand != null;
}
/// さけのわ自動マッチングサービス
///
/// ユーザーが登録した日本酒を自動的にさけのわデータベースとマッチング
/// 銘柄名の統一、正確なフレーバーチャート取得に使用
class SakenowaAutoMatchingService {
final SakenowaService _sakenowaService;
SakenowaAutoMatchingService(this._sakenowaService);
/// 日本酒をさけのわデータベースとマッチング
///
/// [sakeItem]: マッチング対象の日本酒
/// [minScore]: 最小類似度スコア(デフォルト: 0.7
/// [autoApply]: マッチング結果を自動で適用(デフォルト: false
///
/// Returns: マッチング結果見つからない場合はscoreが0
Future<MatchResult> matchSake({
required SakeItem sakeItem,
double minScore = 0.7,
bool autoApply = false,
}) async {
try {
debugPrint('🔍 [SakenowaAutoMatching] 開始: ${sakeItem.displayData.displayName}');
// さけのわデータ取得
final brands = await _sakenowaService.getBrands();
final breweries = await _sakenowaService.getBreweries();
final areas = await _sakenowaService.getAreas();
final flavorCharts = await _sakenowaService.getFlavorCharts();
debugPrint('📊 [SakenowaAutoMatching] データ取得完了: ${brands.length} brands');
// マップ化
final breweryMap = {for (var b in breweries) b.id: b};
final areaMap = {for (var a in areas) a.id: a};
final chartMap = {for (var c in flavorCharts) c.brandId: c};
// 最良マッチを探す
SakenowaBrand? bestBrand;
double bestScore = 0.0;
for (final brand in brands) {
final score = StringSimilarity.sakenowaMatchScore(
sakeItem.displayData.displayName,
brand.name,
);
if (score > bestScore) {
bestScore = score;
bestBrand = brand;
}
}
// 最小スコア未満なら失敗
if (bestScore < minScore) {
debugPrint('❌ [SakenowaAutoMatching] スコア不足: $bestScore < $minScore');
return MatchResult(
score: bestScore,
isConfident: false,
);
}
// マッチング成功
final brewery = bestBrand != null ? breweryMap[bestBrand.breweryId] : null;
final area = brewery != null ? areaMap[brewery.areaId] : null;
final chart = bestBrand != null ? chartMap[bestBrand.id] : null;
debugPrint('✅ [SakenowaAutoMatching] マッチング成功!');
debugPrint(' 銘柄: ${bestBrand?.name} (スコア: $bestScore)');
debugPrint(' 酒蔵: ${brewery?.name}');
debugPrint(' 地域: ${area?.name}');
final result = MatchResult(
brand: bestBrand,
brewery: brewery,
area: area,
flavorChart: chart,
score: bestScore,
isConfident: bestScore >= 0.8,
);
// 自動適用
if (autoApply && result.hasMatch) {
await applyMatch(sakeItem, result);
}
return result;
} catch (e, stackTrace) {
debugPrint('💥 [SakenowaAutoMatching] エラー: $e');
debugPrint('Stack trace: $stackTrace');
return MatchResult(
score: 0.0,
isConfident: false,
);
}
}
/// マッチング結果をSakeItemに適用
///
/// DisplayDataのsakenowaフィールドとHiddenSpecsを更新
Future<void> applyMatch(SakeItem sakeItem, MatchResult result) async {
if (!result.hasMatch) {
debugPrint('⚠️ [SakenowaAutoMatching] マッチなし、適用スキップ');
return;
}
try {
debugPrint('💾 [SakenowaAutoMatching] マッチング結果を適用中...');
// DisplayData更新さけのわ統一名称
final updatedDisplayData = sakeItem.displayData.copyWith(
sakenowaName: result.brand?.name,
sakenowaBrewery: result.brewery?.name,
sakenowaPrefecture: result.area?.name,
);
// HiddenSpecs更新6軸フレーバーチャート
Map<String, double>? flavorChartMap;
if (result.flavorChart != null) {
flavorChartMap = {
'f1': result.flavorChart!.f1,
'f2': result.flavorChart!.f2,
'f3': result.flavorChart!.f3,
'f4': result.flavorChart!.f4,
'f5': result.flavorChart!.f5,
'f6': result.flavorChart!.f6,
};
}
final updatedHiddenSpecs = sakeItem.hiddenSpecs.copyWith(
sakenowaBrandId: result.brand?.id,
sakenowaFlavorChart: flavorChartMap,
);
// SakeItem更新
sakeItem.displayData = updatedDisplayData;
sakeItem.hiddenSpecs = updatedHiddenSpecs;
// Hiveに保存
await sakeItem.save();
debugPrint('✅ [SakenowaAutoMatching] 適用完了!');
debugPrint(' displayName: ${sakeItem.displayData.displayName}');
debugPrint(' displayBrewery: ${sakeItem.displayData.displayBrewery}');
debugPrint(' displayPrefecture: ${sakeItem.displayData.displayPrefecture}');
} catch (e, stackTrace) {
debugPrint('💥 [SakenowaAutoMatching] 適用エラー: $e');
debugPrint('Stack trace: $stackTrace');
}
}
/// 複数の日本酒を一括マッチング
///
/// [sakeItems]: マッチング対象のリスト
/// [minScore]: 最小類似度スコア
/// [autoApply]: マッチング結果を自動で適用
///
/// Returns: マッチング成功数
Future<int> matchBatch({
required List<SakeItem> sakeItems,
double minScore = 0.7,
bool autoApply = false,
}) async {
debugPrint('🔄 [SakenowaAutoMatching] バッチ処理開始: ${sakeItems.length}');
int successCount = 0;
for (final sake in sakeItems) {
final result = await matchSake(
sakeItem: sake,
minScore: minScore,
autoApply: autoApply,
);
if (result.hasMatch && result.isConfident) {
successCount++;
}
}
debugPrint('✅ [SakenowaAutoMatching] バッチ処理完了: $successCount/${sakeItems.length} 成功');
return successCount;
}
/// 既存データのさけのわフィールドをクリア
///
/// デバッグ・テスト用
Future<void> clearSakenowaData(SakeItem sakeItem) async {
debugPrint('🧹 [SakenowaAutoMatching] さけのわデータクリア: ${sakeItem.displayData.displayName}');
final clearedDisplayData = sakeItem.displayData.copyWith(
sakenowaName: null,
sakenowaBrewery: null,
sakenowaPrefecture: null,
);
final clearedHiddenSpecs = sakeItem.hiddenSpecs.copyWith(
sakenowaBrandId: null,
sakenowaFlavorChart: null,
);
sakeItem.displayData = clearedDisplayData;
sakeItem.hiddenSpecs = clearedHiddenSpecs;
await sakeItem.save();
debugPrint('✅ [SakenowaAutoMatching] クリア完了');
}
}