googleVisionService = $googleVisionService; $this->googleMapsService = $googleMapsService; $this->mailSendService = $mailSendService; } /** * SHJ-1メイン処理実行 * * @param int $userId 利用者連番 * @param int $parkId 駐輪場ID * @return array */ public function execute(int $userId, int $parkId): array { try { Log::info('SHJ-1 【処理1】開始: 本人確認自動処理レコード群取得', [ 'user_id' => $userId, 'park_id' => $parkId ]); // 【処理1】本人確認自動処理レコード群取得 - 設計書通りの条件でフィルタリング $user = $this->getUserRecord($userId); if (!$user) { Log::error('SHJ-1 【処理1】失敗: 利用者が見つかりません', [ 'user_id' => $userId, 'park_id' => $parkId ]); return [ 'system_success' => false, 'message' => '利用者が見つかりません', 'stats' => ['error_count' => 1, 'processed_count' => 0] ]; } Log::info('SHJ-1 【処理1】成功: 利用者データ取得', [ 'user_seq' => $user->user_seq, 'user_name' => $user->user_name, 'user_idcard' => $user->user_idcard, 'photo_filename1' => $user->photo_filename1, 'photo_filename2' => $user->photo_filename2 ]); // 本人確認書類が免許証以外、または写真ファイルがない場合は対象外 if (!$this->isTargetUser($user)) { Log::warning('SHJ-1 処理対象外判定', [ 'user_seq' => $user->user_seq, 'user_idcard' => $user->user_idcard, 'photo_filename1' => $user->photo_filename1, 'user_idcard_chk_flag' => $user->user_idcard_chk_flag, 'reason' => '免許証以外または写真なしまたは既に処理済み' ]); return [ 'system_success' => false, 'message' => '処理対象外の利用者です(免許証以外または写真なし)', 'stats' => ['error_count' => 1, 'processed_count' => 0] ]; } $park = $this->getParkRecord($parkId); if (!$park) { Log::error('SHJ-1 駐輪場データ取得失敗', [ 'park_id' => $parkId ]); return [ 'system_success' => false, 'message' => '駐輪場が見つかりません', 'stats' => ['error_count' => 1, 'processed_count' => 0] ]; } Log::info('SHJ-1 駐輪場データ取得成功', [ 'park_id' => $park->park_id, 'park_name' => $park->park_name ]); // トランザクション開始 DB::beginTransaction(); Log::info('SHJ-1 データベーストランザクション開始'); // 【判断1】対象外判定 Log::info('SHJ-1 【判断1】開始: 利用者分類チェック'); $categoryCheck = $this->checkUserCategory($user); if (!$categoryCheck['is_target']) { Log::warning('SHJ-1 【判断1】対象外: 手動処理対象', [ 'user_seq' => $user->user_seq, 'category_name' => $categoryCheck['category_name'], 'reason' => '高齢者、障がい者、生活保護、中国、母子家庭等' ]); $result = $this->processNonTargetUser($user, $categoryCheck['category_name']); DB::commit(); // 対象外処理後はコミット return $result; } Log::info('SHJ-1 【判断1】成功: 自動処理対象', [ 'user_seq' => $user->user_seq, 'category_name' => $categoryCheck['category_name'] ]); // 【処理2】本人確認自動処理+800Mチェック処理 Log::info('SHJ-1 【処理2】開始: 本人確認自動処理'); $identityResult = $this->processIdentityVerification($user, $park); DB::commit(); return $identityResult; } catch (Exception $e) { DB::rollBack(); Log::error('SHJ-1処理エラー', [ 'user_id' => $userId, 'park_id' => $parkId, 'error' => $e->getMessage() ]); return [ 'system_success' => false, 'message' => 'システムエラーが発生しました: ' . $e->getMessage(), 'stats' => ['error_count' => 1, 'processed_count' => 0] ]; } } /** * 利用者レコード取得(設計書の条件に従ってフィルタリング) */ private function getUserRecord(int $userId): ?User { return User::where('user_seq', $userId) ->whereNotNull('photo_filename1') // 本人確認写真必須 ->first(); } /** * 処理対象ユーザーかチェック(設計書の条件) */ private function isTargetUser(User $user): bool { Log::info('SHJ-1 isTargetUser チェック開始', [ 'user_seq' => $user->user_seq, 'user_idcard' => $user->user_idcard, 'photo_filename1' => $user->photo_filename1, 'user_idcard_chk_flag' => $user->user_idcard_chk_flag, 'user_idcard_chk_flag_type' => gettype($user->user_idcard_chk_flag), 'auto_ok_config' => config('shj1.identity_check_status.auto_ok') ]); // 本人確認書類が免許証であること if ($user->user_idcard !== '免許証') { Log::info('SHJ-1 isTargetUser: 免許証以外', ['user_idcard' => $user->user_idcard]); return false; } // 写真ファイルが存在すること if (empty($user->photo_filename1)) { Log::info('SHJ-1 isTargetUser: 写真ファイルなし'); return false; } // 既に本人確認済みの場合はスキップ(一度OKなら再チェック省略) // ※テスト用に一時的にコメントアウト - 重複処理を許可 /* $autoOk = config('shj1.identity_check_status.auto_ok'); if ($user->user_idcard_chk_flag === $autoOk) { // 厳密比較に変更 Log::info('SHJ-1 isTargetUser: 既に処理済み', [ 'current_flag' => $user->user_idcard_chk_flag, 'current_flag_type' => gettype($user->user_idcard_chk_flag), 'auto_ok' => $autoOk, 'auto_ok_type' => gettype($autoOk), 'loose_comparison' => $user->user_idcard_chk_flag == $autoOk ? 'equal' : 'not_equal', 'strict_comparison' => $user->user_idcard_chk_flag === $autoOk ? 'equal' : 'not_equal' ]); return false; } */ Log::info('SHJ-1 isTargetUser: 処理対象OK'); return true; } /** * 駐輪場レコード取得 */ private function getParkRecord(int $parkId): ?Park { return Park::where('park_id', $parkId)->first(); } /** * 利用者分類チェック */ private function checkUserCategory(User $user): array { $usertype = Usertype::where('user_categoryid', $user->user_categoryid)->first(); if (!$usertype) { return ['is_target' => false, 'category_name' => 'unknown']; } $categoryName = $usertype->usertype_subject3; $excludedCategories = config('shj1.user_categories.excluded_categories'); // 対象外カテゴリーかチェック $isExcluded = in_array($categoryName, $excludedCategories); return [ 'is_target' => !$isExcluded, 'category_name' => $categoryName ]; } /** * 対象外ユーザーの処理 */ private function processNonTargetUser(User $user, string $categoryName): array { // 本人確認済ステータスをNGで更新(設計書通り) $autoNgValue = config('shj1.identity_check_status.auto_ng'); Log::info('SHJ-1 対象外ユーザー: ユーザー情報更新開始', [ 'user_seq' => $user->user_seq, 'category_name' => $categoryName, 'before_user_idcard_chk_flag' => $user->user_idcard_chk_flag, 'will_set_to' => $autoNgValue ]); $updateResult = $user->update([ 'user_idcard_chk_flag' => $autoNgValue, 'user_chk_day' => now(), 'user_chk_opeid' => 'SHJ-1' ]); Log::info('SHJ-1 対象外ユーザー: ユーザー情報更新完了', [ 'user_seq' => $user->user_seq, 'update_result' => $updateResult, 'after_user_idcard_chk_flag' => $user->fresh()->user_idcard_chk_flag ]); // オペレータキューも作成(設計書の要求) $this->createOperatorQueue($user, null, [], true); return [ 'system_success' => true, 'identity_result' => 'NG', 'message' => '対象外ユーザーのためNG処理完了', 'details' => [ 'user_id' => $user->user_seq, 'category' => $categoryName, 'action' => 'set_ng_status_with_queue' ], 'stats' => ['processed_count' => 1, 'ng_count' => 1, 'non_target_count' => 1] ]; } /** * 本人確認自動処理+800Mチェック処理(SHJ-1文書仕様) * * 文書仕様: * 1. まずOCR照合処理を実行 * 2. OCR照合成功の場合のみ距離チェック処理を実行 * 3. 両方成功で最終成功 */ private function processIdentityVerification(User $user, Park $park): array { try { // 1. OCR処理による本人確認 Log::info('SHJ-1 【処理2】開始: 本人確認自動処理'); $ocrResult = $this->performComprehensiveOcrVerification($user); // OCR処理失敗の場合、距離チェックを実行せずに失敗処理 if (!$ocrResult['success']) { Log::info('SHJ-1 OCR照合失敗、距離チェックスキップ', [ 'user_seq' => $user->user_seq, 'reason' => $ocrResult['reason'] ]); // 距離チェック結果をダミーで設定(実行しない) $distanceResult = [ 'within_limit' => true, // OCR失敗なので距離は関係ない 'skipped' => true, 'reason' => 'ocr_failed' ]; return $this->processFailureCase($user, $park, $ocrResult, $distanceResult); } // 2. OCR成功 → 距離チェック処理を実行 Log::info('SHJ-1 OCR照合成功、距離チェック開始', [ 'user_seq' => $user->user_seq, 'matched_address_type' => $ocrResult['matched_address_type'] ]); $distanceResult = $this->performDistanceCheck($user, $park); // 3. 最終判定:OCR成功 AND 距離内 if ($distanceResult['within_limit']) { return $this->processSuccessCase($user, $park, $ocrResult, $distanceResult); } else { return $this->processFailureCase($user, $park, $ocrResult, $distanceResult); } } catch (Exception $e) { Log::error('SHJ-1 【処理2】エラー', [ 'user_seq' => $user->user_seq, 'park_id' => $park->park_id, 'error' => $e->getMessage() ]); // システムエラーとして失敗処理 return $this->processFailureCase($user, $park, ['success' => false, 'reason' => 'system_error'], ['within_limit' => true, 'skipped' => true, 'reason' => 'system_error'] ); } } /** * 包括的OCR処理(SHJ-1文書仕様に従った実装) * * 文書仕様: * - 表面画像で一度OKが出た場合、裏面のチェックはスキップする * - [OCR值]と[利用者住所]を照合する */ private function performComprehensiveOcrVerification(User $user): array { try { Log::info('SHJ-1 OCR処理開始', [ 'user_seq' => $user->user_seq, 'photo_filename1' => $user->photo_filename1, 'photo_filename2' => $user->photo_filename2 ]); // 表面画像の処理 if ($user->photo_filename1) { $photoPath = $this->buildPhotoPath($user->photo_filename1); Log::info('SHJ-1 表面画像処理開始', [ 'user_seq' => $user->user_seq, 'photo_filename1' => $user->photo_filename1, 'photo_path' => $photoPath ]); $frontResult = $this->processImageForOcr($user, $photoPath, 'front'); Log::info('SHJ-1 表面画像処理完了', [ 'user_seq' => $user->user_seq, 'front_result' => $frontResult ]); // SHJ-1文書通り:一度OKが出た場合、裏面チェックはスキップ if ($frontResult['success']) { Log::info('SHJ-1 表面画像でOK、裏面チェックスキップ', [ 'user_seq' => $user->user_seq ]); return [ 'success' => true, 'processed_side' => 'front', 'extracted_name' => $frontResult['extracted_name'], 'extracted_address' => $frontResult['extracted_address'], 'ocr_value' => $frontResult['ocr_value'], 'matched_address_type' => $frontResult['matched_address_type'], 'matched_address' => $frontResult['matched_address'], 'similarity' => $frontResult['similarity'], 'reason' => $frontResult['reason'] ]; } } // 表面画像がNGまたは存在しない場合、裏面画像を処理 if ($user->photo_filename2) { $photoPath = $this->buildPhotoPath($user->photo_filename2); Log::info('SHJ-1 裏面画像処理開始', [ 'user_seq' => $user->user_seq, 'photo_filename2' => $user->photo_filename2, 'photo_path' => $photoPath ]); $backResult = $this->processImageForOcr($user, $photoPath, 'back'); Log::info('SHJ-1 裏面画像処理完了', [ 'user_seq' => $user->user_seq, 'back_result' => $backResult ]); if ($backResult['success']) { return [ 'success' => true, 'processed_side' => 'back', 'extracted_name' => $backResult['extracted_name'], 'extracted_address' => $backResult['extracted_address'], 'ocr_value' => $backResult['ocr_value'], 'matched_address_type' => $backResult['matched_address_type'], 'matched_address' => $backResult['matched_address'], 'similarity' => $backResult['similarity'], 'reason' => $backResult['reason'] ]; } // 裏面もNG return [ 'success' => false, 'processed_side' => 'both', 'reason' => 'both_sides_failed', 'front_result' => $frontResult ?? null, 'back_result' => $backResult ]; } // 表面のみでNG return [ 'success' => false, 'processed_side' => 'front_only', 'reason' => 'front_failed_no_back', 'front_result' => $frontResult ?? null ]; } catch (Exception $e) { Log::error('SHJ-1 OCR処理エラー', [ 'user_seq' => $user->user_seq, 'error' => $e->getMessage() ]); return [ 'success' => false, 'reason' => 'system_error', 'error' => $e->getMessage() ]; } } /** * 照片文件路径构建(Laravel Storage方式) */ private function buildPhotoPath(string $filename): string { // Storage::disk('public')用,需要 "photo/filename" 格式 return 'photo/' . $filename; } /** * 画像OCR処理(SHJ-1文書仕様に従った実装) */ private function processImageForOcr(User $user, string $filename, string $side): array { // Google Vision APIでOCR実行 $ocrText = $this->googleVisionService->extractTextFromImage($filename); // 完全なOCR認識結果をログ出力 Log::info('SHJ-1 完全OCR認識結果', [ 'user_seq' => $user->user_seq, 'filename' => $filename, 'side' => $side, 'ocr_text_length' => strlen($ocrText), 'ocr_full_text' => base64_encode($ocrText) ]); if (empty($ocrText)) { Log::error('SHJ-1 OCR結果が空', ['user_seq' => $user->user_seq, 'filename' => $filename]); return [ 'success' => false, 'reason' => 'ocr_empty', 'extracted_name' => '', 'extracted_address' => '', 'ocr_value' => '' ]; } // SHJ-1文書通り:OCR文本からの氏名・住所抽出 $extractResult = $this->extractNameAndAddressFromOcr($ocrText); if (!$extractResult['success']) { Log::error('SHJ-1 OCR抽出失敗', [ 'user_seq' => $user->user_seq, 'filename' => $filename, 'reason' => $extractResult['reason'] ]); return $extractResult; } Log::info('SHJ-1 OCR抽出成功', [ 'user_seq' => $user->user_seq, 'filename' => $filename, 'side' => $side, 'extracted_name' => $extractResult['extracted_name'], 'extracted_address' => $extractResult['extracted_address'], 'ocr_value' => $extractResult['ocr_value'] ]); // SHJ-1文書通り:[OCR値]と[利用者住所]の照合 $matchResult = $this->performOcrMatching($user, $extractResult['ocr_value']); return array_merge($extractResult, $matchResult); } /** * SHJ-1文書通り:OCRテキストから氏名と住所を抽出 * * 文書仕様: * 1. "氏名"があるかチェック * 2. "氏名"の次から年号和暦(昭和|平成)まで → [氏名] * 3. "住所"があるかチェック * 4. "住所"の次から和暦(昭和|平成|令和)または"交付"まで → [住所] * 5. [OCR値] = [氏名] + [住所] */ private function extractNameAndAddressFromOcr(string $ocrText): array { // ノイズ除去:改行とスペースを統一 $cleanedOcr = preg_replace('/\s+/', '', $ocrText); // 1. "氏名"キーワードの存在チェック if (strpos($cleanedOcr, '氏名') === false) { return [ 'success' => false, 'reason' => 'name_keyword_not_found', 'extracted_name' => '', 'extracted_address' => '', 'ocr_value' => '' ]; } // 2. 氏名抽出:「氏名」の次から「住所」まで $namePattern = '/氏名([^住]*?)住所/u'; if (!preg_match($namePattern, $cleanedOcr, $nameMatches)) { return [ 'success' => false, 'reason' => 'name_extraction_failed', 'extracted_name' => '', 'extracted_address' => '', 'ocr_value' => '' ]; } $extractedName = $this->removeSymbols($nameMatches[1]); // 3. "住所"キーワードの存在チェック if (strpos($cleanedOcr, '住所') === false) { return [ 'success' => false, 'reason' => 'address_keyword_not_found', 'extracted_name' => $extractedName, 'extracted_address' => '', 'ocr_value' => '' ]; } // 4. 住所抽出:「住所」の次から改行文字または「交付」まで $addressPattern = '/住所([^\r\n|]*?)(?:\r|\n|\|交付|交付)/u'; if (!preg_match($addressPattern, $cleanedOcr, $addressMatches)) { return [ 'success' => false, 'reason' => 'address_extraction_failed', 'extracted_name' => $extractedName, 'extracted_address' => '', 'ocr_value' => '' ]; } $extractedAddress = $this->removeSymbols($addressMatches[1]); // 5. SHJ-1文書通り:[OCR値] = [氏名] + [住所] $ocrValue = $extractedName . $extractedAddress; return [ 'success' => true, 'extracted_name' => $extractedName, 'extracted_address' => $extractedAddress, 'ocr_value' => $ocrValue ]; } /** * 記号を除去(SHJ-1文書仕様) */ private function removeSymbols(string $text): string { // 基本的な記号・区切り文字を除去 $cleaned = preg_replace('/[|\-\|\/\\\\()()\[\]【】「」『』<>《》≪≫]/u', '', $text); $cleaned = preg_replace('/\s+/u', '', $cleaned); // スペース除去 return trim($cleaned); } /** * SHJ-1文書通り:[OCR值]と[利用者住所]の照合処理 * * 文書仕様: * 1. [利用者居住所]と[OCR値]を照合 → 閾値以下なら次へ * 2. [利用者関連住所]と[OCR値]を照合 → 閾値以下ならOCR照合失敗 */ private function performOcrMatching(User $user, string $ocrValue): array { $threshold = config('shj1.ocr.similarity_threshold'); // [利用者居住所]を構築 $residentAddress = $user->user_regident_pre . $user->user_regident_city . $user->user_regident_add; // 1. [利用者居住所]と[OCR値]を照合 similar_text($residentAddress, $ocrValue, $residentSimilarity); Log::info('SHJ-1 居住住所照合', [ 'user_seq' => $user->user_seq, 'resident_address' => $residentAddress, 'ocr_value' => $ocrValue, 'similarity' => $residentSimilarity, 'threshold' => $threshold ]); if ($residentSimilarity >= $threshold) { // 居住住所で照合成功 Log::info('SHJ-1 OCR照合成功(居住住所)', [ 'user_seq' => $user->user_seq, 'matched_address_type' => '居住住所', 'similarity' => $residentSimilarity ]); return [ 'success' => true, 'matched_address_type' => '居住住所', 'matched_address' => $residentAddress, 'similarity' => $residentSimilarity, 'reason' => 'resident_address_matched' ]; } // 2. [利用者関連住所]と[OCR値]を照合 $relatedAddress = $user->user_relate_pre . $user->user_relate_city . $user->user_relate_add; if (!empty(trim($relatedAddress))) { similar_text($relatedAddress, $ocrValue, $relatedSimilarity); Log::info('SHJ-1 関連住所照合', [ 'user_seq' => $user->user_seq, 'related_address' => $relatedAddress, 'ocr_value' => $ocrValue, 'similarity' => $relatedSimilarity, 'threshold' => $threshold ]); if ($relatedSimilarity >= $threshold) { // 関連住所で照合成功 Log::info('SHJ-1 OCR照合成功(関連住所)', [ 'user_seq' => $user->user_seq, 'matched_address_type' => '関連住所', 'similarity' => $relatedSimilarity ]); return [ 'success' => true, 'matched_address_type' => '関連住所', 'matched_address' => $relatedAddress, 'similarity' => $relatedSimilarity, 'reason' => 'related_address_matched' ]; } } // 両方とも閾值以下:OCR照合失敗 Log::info('SHJ-1 OCR照合失敗', [ 'user_seq' => $user->user_seq, 'resident_similarity' => $residentSimilarity, 'related_similarity' => $relatedAddress ? ($relatedSimilarity ?? 0) : 'N/A', 'threshold' => $threshold ]); return [ 'success' => false, 'matched_address_type' => null, 'matched_address' => null, 'similarity' => max($residentSimilarity, $relatedSimilarity ?? 0), 'reason' => 'threshold_not_met' ]; } /** * 距離チェック処理(設計書通りpark.distance_between_two_pointsを使用) */ private function performDistanceCheck(User $user, Park $park): array { try { // ユーザー住所を構築 $userAddress = $user->user_regident_pre . $user->user_regident_city . $user->user_regident_add; // 駐輪場住所 $parkAddress = $park->park_adrs; Log::info('SHJ-1 距離計算開始', [ 'user_seq' => $user->user_seq, 'park_id' => $park->park_id, 'user_address' => $userAddress, 'park_address' => $parkAddress ]); // Google Maps APIで距離計算 $distanceResult = $this->googleMapsService->calculateDistance($userAddress, $parkAddress); $distanceMeters = $distanceResult['distance_meters']; // 駐輪場の二点間距離制限を取得(設計書の要求) $limitMeters = $park->distance_between_two_points ?? config('shj1.distance.default_limit_meters'); $withinLimit = $distanceMeters <= $limitMeters; $distanceDetail = $this->googleMapsService->generateDistanceDetailString( $park->park_id, $distanceMeters, 'google_maps' ); Log::info('SHJ-1 距離計算完了', [ 'user_seq' => $user->user_seq, 'park_id' => $park->park_id, 'calculated_distance_meters' => $distanceMeters, 'distance_text' => $distanceResult['distance_text'] ?? null, 'limit_meters' => $limitMeters, 'within_limit' => $withinLimit, 'distance_detail' => $distanceDetail ]); return [ 'within_limit' => $withinLimit, 'distance_meters' => $distanceMeters, 'limit_meters' => $limitMeters, 'distance_detail' => $distanceDetail, 'user_address' => $userAddress, 'park_address' => $parkAddress ]; } catch (Exception $e) { Log::error('Distance check error', [ 'user_id' => $user->user_seq, 'park_id' => $park->park_id, 'error' => $e->getMessage() ]); // API失敗時は距離NGとして処理(設計書の要求) return [ 'within_limit' => false, 'distance_meters' => 999999, 'limit_meters' => $park->distance_between_two_points ?? config('shj1.distance.default_limit_meters'), 'error' => $e->getMessage(), 'distance_detail' => $park->park_id . "/" . $park->park_id . "/API Error: " . $e->getMessage() ]; } } /** * 成功ケースの処理 */ private function processSuccessCase(User $user, Park $park, array $ocrResult, array $distanceResult): array { // ユーザー情報更新 $autoOkValue = config('shj1.identity_check_status.auto_ok'); Log::info('SHJ-1 成功ケース: ユーザー情報更新開始', [ 'user_seq' => $user->user_seq, 'before_user_idcard_chk_flag' => $user->user_idcard_chk_flag, 'will_set_to' => $autoOkValue ]); $updateResult = $user->update([ 'user_idcard_chk_flag' => $autoOkValue, 'user_chk_day' => now(), 'user_chk_opeid' => 'SHJ-1' ]); Log::info('SHJ-1 成功ケース: ユーザー情報更新完了', [ 'user_seq' => $user->user_seq, 'update_result' => $updateResult, 'after_user_idcard_chk_flag' => $user->fresh()->user_idcard_chk_flag ]); // 成功メール送信 $this->sendSuccessEmail($user, $park); return [ 'system_success' => true, 'identity_result' => 'OK', 'message' => '本人確認自動処理が成功しました', 'details' => [ 'user_id' => $user->user_seq, 'park_id' => $park->park_id, 'ocr_result' => $ocrResult['reason'], 'distance_check' => 'within_limit' ], 'stats' => ['processed_count' => 1, 'success_count' => 1] ]; } /** * 失敗ケースの処理 */ private function processFailureCase(User $user, Park $park, array $ocrResult, array $distanceResult): array { // ユーザー情報更新(設計書通り、NG時もuser_chk_dayを更新) $autoNgValue = config('shj1.identity_check_status.auto_ng'); Log::info('SHJ-1 失敗ケース: ユーザー情報更新開始', [ 'user_seq' => $user->user_seq, 'before_user_idcard_chk_flag' => $user->user_idcard_chk_flag, 'will_set_to' => $autoNgValue, 'ocr_result' => $ocrResult['reason'] ?? 'unknown', 'distance_within_limit' => $distanceResult['within_limit'] ?? 'unknown' ]); $updateResult = $user->update([ 'user_idcard_chk_flag' => $autoNgValue, 'user_chk_day' => now(), 'user_chk_opeid' => 'SHJ-1' ]); Log::info('SHJ-1 失敗ケース: ユーザー情報更新完了', [ 'user_seq' => $user->user_seq, 'update_result' => $updateResult, 'after_user_idcard_chk_flag' => $user->fresh()->user_idcard_chk_flag ]); // オペレータキュー作成 $this->createOperatorQueue($user, $park, $distanceResult); // 800M違反フラグ更新(距離NGの場合) if (!$distanceResult['within_limit']) { $this->update800mFlag($user, $park); } // 失敗メール送信 $this->sendFailureEmail($user, $park); return [ 'system_success' => true, 'identity_result' => 'NG', 'message' => '本人確認自動処理NGのため手動処理キューを作成しました', 'details' => [ 'user_id' => $user->user_seq, 'park_id' => $park->park_id, 'ocr_result' => $ocrResult['reason'], 'distance_check' => $distanceResult['within_limit'] ? 'within_limit' : 'over_limit' ], 'stats' => ['processed_count' => 1, 'ng_count' => 1] ]; } /** * オペレータキュー作成 */ private function createOperatorQueue(User $user, ?Park $park, array $distanceResult, bool $isNonTarget = false): void { $queueType = $user->user_school ? config('shj1.operator_queue.queue_types.student') : config('shj1.operator_queue.queue_types.general'); $comment = config('shj1.operator_queue.default_comment'); if (isset($distanceResult['distance_detail'])) { $comment .= " / " . $distanceResult['distance_detail']; } if ($isNonTarget) { $comment .= " / 対象外ユーザー"; } OperatorQue::create([ 'que_class' => $queueType, 'user_id' => $user->user_seq, 'park_id' => $park ? $park->park_id : null, 'que_status' => config('shj1.operator_queue.queue_status.created'), 'que_status_comment' => $comment, 'operator_id' => config('shj1.operator_queue.batch_operator_id'), 'created_at' => now(), 'updated_at' => now() ]); } /** * 800M違反フラグ更新(設計書通り本人確認オペレータIDも更新) */ private function update800mFlag(User $user, Park $park): void { RegularContract::where('user_id', $user->user_seq) ->where('park_id', $park->park_id) ->update([ '800m_flag' => config('shj1.contract_800m.violation_flag'), 'updated_at' => now() ]); } /** * 成功メール送信 */ private function sendSuccessEmail(User $user, Park $park): void { try { $this->mailSendService->executeMailSend( $user->user_primemail, $user->user_submail, config('shj1.mail.program_id_success') ); } catch (Exception $e) { Log::error('Success email sending failed', [ 'user_id' => $user->user_seq, 'error' => $e->getMessage() ]); } } /** * 失敗メール送信 */ private function sendFailureEmail(User $user, Park $park): void { try { $this->mailSendService->executeMailSend( $user->user_primemail, $user->user_submail, config('shj1.mail.program_id_failure') ); } catch (Exception $e) { Log::error('Failure email sending failed', [ 'user_id' => $user->user_seq, 'error' => $e->getMessage() ]); } } /** * 分析用テキスト正規化 */ private function normalizeForAnalysis(string $text): string { // 空白・改行除去 $text = preg_replace('/\s+/', '', $text); // 全角→半角統一 $text = mb_convert_kana($text, 'rnask', 'UTF-8'); // 住所同義語統一 $text = str_replace(['東京市', '東京府'], '東京都', $text); $text = str_replace(['の'], '', $text); return $text; } /** * 文字レベル一致分析 */ private function analyzeCharacterMatches(string $expected, string $ocrText): array { $expectedChars = mb_str_split($expected, 1, 'UTF-8'); $normalizedOcr = $this->normalizeForAnalysis($ocrText); $analysis = [ 'total_chars' => count($expectedChars), 'matched_chars' => 0, 'missing_chars' => [], 'match_rate' => 0 ]; foreach ($expectedChars as $char) { if (mb_strpos($normalizedOcr, $char, 0, 'UTF-8') !== false) { $analysis['matched_chars']++; } else { $analysis['missing_chars'][] = $char; } } $analysis['match_rate'] = round(($analysis['matched_chars'] / $analysis['total_chars']) * 100, 2); return $analysis; } }