237 lines
7.4 KiB
Dart
237 lines
7.4 KiB
Dart
|
|
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] クリア完了');
|
|||
|
|
}
|
|||
|
|
}
|