1102 lines
42 KiB
PHP
1102 lines
42 KiB
PHP
<?php
|
||
|
||
namespace App\Services;
|
||
|
||
use App\Models\User;
|
||
use App\Models\Usertype;
|
||
use App\Models\Park;
|
||
use App\Models\RegularContract;
|
||
use App\Models\OperatorQue;
|
||
use App\Models\Device;
|
||
use App\Services\GoogleVisionService;
|
||
use App\Services\GoogleMapsService;
|
||
use App\Services\ShjMailSendService;
|
||
use App\Services\ShjEightService;
|
||
use Exception;
|
||
use Illuminate\Support\Facades\Log;
|
||
use Illuminate\Support\Facades\DB;
|
||
|
||
/**
|
||
* SHJ-1 本人確認自動処理 Service
|
||
*/
|
||
class ShjOneService
|
||
{
|
||
protected $googleVisionService;
|
||
protected $googleMapsService;
|
||
protected $mailSendService;
|
||
protected $shjEightService;
|
||
|
||
public function __construct(
|
||
GoogleVisionService $googleVisionService,
|
||
GoogleMapsService $googleMapsService,
|
||
ShjMailSendService $mailSendService,
|
||
ShjEightService $shjEightService
|
||
) {
|
||
$this->googleVisionService = $googleVisionService;
|
||
$this->googleMapsService = $googleMapsService;
|
||
$this->mailSendService = $mailSendService;
|
||
$this->shjEightService = $shjEightService;
|
||
}
|
||
|
||
/**
|
||
* 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
|
||
]);
|
||
$result = [
|
||
'system_success' => false,
|
||
'message' => '利用者が見つかりません',
|
||
'stats' => ['error_count' => 1, 'processed_count' => 0]
|
||
];
|
||
$this->createBatchLog($userId, null, null, $result);
|
||
return $result;
|
||
}
|
||
|
||
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' => '免許証以外または写真なしまたは既に処理済み'
|
||
]);
|
||
$result = [
|
||
'system_success' => false,
|
||
'message' => '処理対象外の利用者です(免許証以外または写真なし)',
|
||
'stats' => ['error_count' => 1, 'processed_count' => 0]
|
||
];
|
||
$this->createBatchLog($userId, $user->user_name, null, $result);
|
||
return $result;
|
||
}
|
||
|
||
$park = $this->getParkRecord($parkId);
|
||
if (!$park) {
|
||
Log::error('SHJ-1 駐輪場データ取得失敗', [
|
||
'park_id' => $parkId
|
||
]);
|
||
$result = [
|
||
'system_success' => false,
|
||
'message' => '駐輪場が見つかりません',
|
||
'stats' => ['error_count' => 1, 'processed_count' => 0]
|
||
];
|
||
$this->createBatchLog($userId, $user->user_name, null, $result);
|
||
return $result;
|
||
}
|
||
|
||
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(); // 対象外処理後はコミット
|
||
$this->createBatchLog($userId, $user->user_name, null, $result);
|
||
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();
|
||
|
||
// バッチ処理ログ作成
|
||
$this->createBatchLog($userId, $user->user_name, $identityResult['similarity_rate'] ?? null, $identityResult);
|
||
|
||
return $identityResult;
|
||
|
||
} catch (Exception $e) {
|
||
DB::rollBack();
|
||
Log::error('SHJ-1処理エラー', [
|
||
'user_id' => $userId,
|
||
'park_id' => $parkId,
|
||
'error' => $e->getMessage()
|
||
]);
|
||
|
||
$result = [
|
||
'system_success' => false,
|
||
'message' => 'システムエラーが発生しました: ' . $e->getMessage(),
|
||
'stats' => ['error_count' => 1, 'processed_count' => 0]
|
||
];
|
||
|
||
// エラー時もバッチログ作成
|
||
$this->createBatchLog($userId, null, null, $result);
|
||
|
||
return $result;
|
||
}
|
||
}
|
||
|
||
/**
|
||
* 利用者レコード取得(設計書の条件に従ってフィルタリング)
|
||
*/
|
||
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' => '本人確認自動処理が成功しました',
|
||
'similarity_rate' => $ocrResult['similarity'] ?? 0,
|
||
'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のため手動処理キューを作成しました',
|
||
'similarity_rate' => $ocrResult['similarity'] ?? 0,
|
||
'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 {
|
||
// SHJ-7 メール送信処理
|
||
$mailResult = $this->mailSendService->executeMailSend(
|
||
$user->user_primemail,
|
||
$user->user_submail,
|
||
config('shj1.mail.program_id_success')
|
||
);
|
||
|
||
// SHJ-7仕様準拠: result === 0 が正常、それ以外は異常
|
||
if (($mailResult['result'] ?? 1) === 0) {
|
||
Log::info('SHJ-1 成功メール送信成功', [
|
||
'user_id' => $user->user_seq,
|
||
'email' => $user->user_primemail
|
||
]);
|
||
} else {
|
||
// 仕様準拠: error_info をログに記録
|
||
Log::error('SHJ-1 成功メール送信失敗', [
|
||
'user_id' => $user->user_seq,
|
||
'email' => $user->user_primemail,
|
||
'error_info' => $mailResult['error_info'] ?? 'メール送信失敗'
|
||
]);
|
||
}
|
||
} catch (Exception $e) {
|
||
Log::error('SHJ-1 成功メール送信例外エラー', [
|
||
'user_id' => $user->user_seq,
|
||
'error' => $e->getMessage()
|
||
]);
|
||
}
|
||
}
|
||
|
||
/**
|
||
* 失敗メール送信
|
||
*/
|
||
private function sendFailureEmail(User $user, Park $park): void
|
||
{
|
||
try {
|
||
// SHJ-7 メール送信処理
|
||
$mailResult = $this->mailSendService->executeMailSend(
|
||
$user->user_primemail,
|
||
$user->user_submail,
|
||
config('shj1.mail.program_id_failure')
|
||
);
|
||
|
||
// SHJ-7仕様準拠: result === 0 が正常、それ以外は異常
|
||
if (($mailResult['result'] ?? 1) === 0) {
|
||
Log::info('SHJ-1 失敗メール送信成功', [
|
||
'user_id' => $user->user_seq,
|
||
'email' => $user->user_primemail
|
||
]);
|
||
} else {
|
||
// 仕様準拠: error_info をログに記録
|
||
Log::error('SHJ-1 失敗メール送信失敗', [
|
||
'user_id' => $user->user_seq,
|
||
'email' => $user->user_primemail,
|
||
'error_info' => $mailResult['error_info'] ?? 'メール送信失敗'
|
||
]);
|
||
}
|
||
} catch (Exception $e) {
|
||
Log::error('SHJ-1 失敗メール送信例外エラー', [
|
||
'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;
|
||
}
|
||
|
||
/**
|
||
* バッチ処理ログ作成
|
||
*
|
||
* SHJ-8サービスを呼び出してbat_job_logテーブルに登録
|
||
*
|
||
* @param int $userId 利用者連番
|
||
* @param string|null $userName 利用者名
|
||
* @param float|null $similarityRate 類似度
|
||
* @param array $result 処理結果
|
||
* @return void
|
||
*/
|
||
private function createBatchLog(int $userId, ?string $userName, ?float $similarityRate, array $result): void
|
||
{
|
||
try {
|
||
$device = Device::orderBy('device_id')->first();
|
||
$deviceId = $device ? $device->device_id : 1;
|
||
$today = now()->format('Y/m/d');
|
||
|
||
// ステータス判定
|
||
$status = $result['system_success'] ? 'success' : 'error';
|
||
|
||
// ステータスコメント生成: {user_id}/{user_name}/{similarity_rate}%
|
||
$displayUserId = $userId;
|
||
$displayUserName = $userName ?? '不明';
|
||
$displaySimilarity = $similarityRate !== null ? number_format($similarityRate, 1) : '0.0';
|
||
|
||
$statusComment = "{$displayUserId}/{$displayUserName}/{$displaySimilarity}%";
|
||
|
||
// SHJ-8サービス呼び出し
|
||
$this->shjEightService->execute(
|
||
$deviceId,
|
||
'SHJ-1',
|
||
'SHJ-1本人確認自動処理',
|
||
$status,
|
||
$statusComment,
|
||
$today,
|
||
$today
|
||
);
|
||
|
||
Log::info('SHJ-1 バッチ処理ログ作成完了', [
|
||
'user_id' => $userId,
|
||
'status' => $status,
|
||
'status_comment' => $statusComment
|
||
]);
|
||
|
||
} catch (Exception $e) {
|
||
Log::error('SHJ-1 バッチ処理ログ作成エラー', [
|
||
'error' => $e->getMessage(),
|
||
'user_id' => $userId
|
||
]);
|
||
}
|
||
}
|
||
|
||
} |