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

237 lines
7.4 KiB
Dart
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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] クリア完了');
}
}