Files
sam-manage/app/Services/TradingPartnerOcrService.php

306 lines
9.6 KiB
PHP
Raw Normal View History

<?php
namespace App\Services;
use App\Helpers\AiTokenHelper;
use App\Models\System\AiConfig;
use Illuminate\Http\Client\ConnectionException;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Facades\Log;
class TradingPartnerOcrService
{
/**
* 사업자등록증 이미지에서 정보 추출
*/
public function extractFromImage(string $base64Image): array
{
$config = AiConfig::getActiveGemini();
if (! $config) {
throw new \RuntimeException('Gemini API 설정이 없습니다. 시스템 설정에서 AI 설정을 추가해주세요.');
}
if ($config->isVertexAi()) {
return $this->callVertexAiApi($config, $base64Image);
}
return $this->callGoogleAiStudioApi($config, $base64Image);
}
/**
* Vertex AI API 호출 (Google Cloud - 서비스 계정 인증)
*/
private function callVertexAiApi(AiConfig $config, string $base64Image): array
{
$model = $config->model;
$projectId = $config->getProjectId();
$region = $config->getRegion();
2026-02-25 11:45:01 +09:00
if (! $projectId) {
throw new \RuntimeException('Vertex AI 프로젝트 ID가 설정되지 않았습니다.');
}
$accessToken = $this->getAccessToken($config);
2026-02-25 11:45:01 +09:00
if (! $accessToken) {
throw new \RuntimeException('Google Cloud 인증 실패');
}
$url = "https://{$region}-aiplatform.googleapis.com/v1/projects/{$projectId}/locations/{$region}/publishers/google/models/{$model}:generateContent";
return $this->callGeminiApi($url, $base64Image, [
2026-02-25 11:45:01 +09:00
'Authorization' => 'Bearer '.$accessToken,
'Content-Type' => 'application/json',
], true);
}
/**
* Google AI Studio API 호출 (API 인증)
*/
private function callGoogleAiStudioApi(AiConfig $config, string $base64Image): array
{
$model = $config->model;
$apiKey = $config->api_key;
$baseUrl = $config->base_url ?? 'https://generativelanguage.googleapis.com/v1beta';
$url = "{$baseUrl}/models/{$model}:generateContent?key={$apiKey}";
return $this->callGeminiApi($url, $base64Image, [
'Content-Type' => 'application/json',
], false);
}
/**
* Gemini API 공통 호출 로직
*/
private function callGeminiApi(string $url, string $base64Image, array $headers, bool $isVertexAi = false): array
{
$imageData = $base64Image;
$mimeType = 'image/jpeg';
if (preg_match('/^data:(image\/\w+);base64,/', $base64Image, $matches)) {
$mimeType = $matches[1];
$imageData = preg_replace('/^data:image\/\w+;base64,/', '', $base64Image);
}
$prompt = $this->buildPrompt();
$content = [
'parts' => [
[
'inlineData' => [
'mimeType' => $mimeType,
'data' => $imageData,
],
],
[
'text' => $prompt,
],
],
];
if ($isVertexAi) {
$content['role'] = 'user';
}
try {
$response = Http::timeout(30)
->withHeaders($headers)
->post($url, [
'contents' => [$content],
'generationConfig' => [
'temperature' => 0.1,
'topK' => 40,
'topP' => 0.95,
'maxOutputTokens' => 1024,
'responseMimeType' => 'application/json',
],
]);
if (! $response->successful()) {
Log::error('Gemini API error (사업자등록증OCR)', [
'status' => $response->status(),
'body' => $response->body(),
]);
2026-02-25 11:45:01 +09:00
throw new \RuntimeException('AI API 호출 실패: '.$response->status());
}
$result = $response->json();
AiTokenHelper::saveGeminiUsage($result, $result['modelVersion'] ?? 'gemini', '사업자등록증OCR');
$text = $result['candidates'][0]['content']['parts'][0]['text'] ?? '';
$parsed = json_decode($text, true);
if (json_last_error() !== JSON_ERROR_NONE) {
Log::warning('AI response JSON parse failed (사업자등록증OCR)', ['text' => $text]);
throw new \RuntimeException('AI 응답 파싱 실패');
}
return [
'ok' => true,
'data' => $this->normalizeData($parsed),
'raw_response' => $text,
];
} catch (ConnectionException $e) {
Log::error('Gemini API connection failed (사업자등록증OCR)', ['error' => $e->getMessage()]);
throw new \RuntimeException('AI API 연결 실패');
}
}
/**
* 서비스 계정으로 OAuth2 액세스 토큰 가져오기
*/
private function getAccessToken(AiConfig $config): ?string
{
$configuredPath = $config->getServiceAccountPath();
$possiblePaths = array_filter([
$configuredPath,
'/var/www/sales/apikey/google_service_account.json',
storage_path('app/google_service_account.json'),
]);
$serviceAccountPath = null;
foreach ($possiblePaths as $path) {
if ($path && file_exists($path)) {
$serviceAccountPath = $path;
break;
}
}
2026-02-25 11:45:01 +09:00
if (! $serviceAccountPath) {
Log::error('Service account file not found', ['tried_paths' => $possiblePaths]);
2026-02-25 11:45:01 +09:00
return null;
}
$serviceAccount = json_decode(file_get_contents($serviceAccountPath), true);
2026-02-25 11:45:01 +09:00
if (! $serviceAccount) {
Log::error('Service account JSON parse failed');
2026-02-25 11:45:01 +09:00
return null;
}
$now = time();
$jwtHeader = $this->base64UrlEncode(json_encode(['alg' => 'RS256', 'typ' => 'JWT']));
$jwtClaim = $this->base64UrlEncode(json_encode([
'iss' => $serviceAccount['client_email'],
'scope' => 'https://www.googleapis.com/auth/cloud-platform',
'aud' => 'https://oauth2.googleapis.com/token',
'exp' => $now + 3600,
'iat' => $now,
]));
$privateKey = openssl_pkey_get_private($serviceAccount['private_key']);
2026-02-25 11:45:01 +09:00
if (! $privateKey) {
Log::error('Failed to load private key');
2026-02-25 11:45:01 +09:00
return null;
}
2026-02-25 11:45:01 +09:00
openssl_sign($jwtHeader.'.'.$jwtClaim, $signature, $privateKey, OPENSSL_ALGO_SHA256);
$jwt = $jwtHeader.'.'.$jwtClaim.'.'.$this->base64UrlEncode($signature);
try {
$response = Http::asForm()->post('https://oauth2.googleapis.com/token', [
'grant_type' => 'urn:ietf:params:oauth:grant-type:jwt-bearer',
'assertion' => $jwt,
]);
if ($response->successful()) {
$data = $response->json();
2026-02-25 11:45:01 +09:00
return $data['access_token'] ?? null;
}
Log::error('OAuth token request failed', [
'status' => $response->status(),
'body' => $response->body(),
]);
2026-02-25 11:45:01 +09:00
return null;
} catch (\Exception $e) {
Log::error('OAuth token request exception', ['error' => $e->getMessage()]);
2026-02-25 11:45:01 +09:00
return null;
}
}
/**
* Base64 URL 인코딩
*/
private function base64UrlEncode(string $data): string
{
return rtrim(strtr(base64_encode($data), '+/', '-_'), '=');
}
/**
* 사업자등록증 OCR 프롬프트 생성
*/
private function buildPrompt(): string
{
2026-02-25 11:45:01 +09:00
return <<<'PROMPT'
사업자등록증 이미지에서 다음 정보를 추출해주세요.
## 추출 항목
1. company_name: 상호 (법인명)
2. business_number: 사업자등록번호 (000-00-00000 형식)
3. ceo_name: 대표자명
4. contact_phone: 전화번호
5. email: 이메일 (있는 경우)
6. address: 사업장 소재지 (주소)
7. biz_type: 업태
8. biz_item: 종목
## 규칙
1. 정보가 없으면 문자열("") 응답
2. 사업자번호는 10자리 숫자를 000-00-00000 형식으로 변환
3. 전화번호는 하이픈 포함 형식 유지
4. 한국어로 정보를 우선 추출
5. 법인명과 상호가 모두 있으면 상호를 우선 사용
## 출력 형식 (JSON)
{
"company_name": "",
"business_number": "",
"ceo_name": "",
"contact_phone": "",
"email": "",
"address": "",
"biz_type": "",
"biz_item": ""
}
JSON 형식으로만 응답하세요.
PROMPT;
}
/**
* 추출된 데이터를 거래처 필드에 맞게 정규화
*/
private function normalizeData(array $data): array
{
// 사업자번호 정규화
if (! empty($data['business_number'])) {
$digits = preg_replace('/\D/', '', $data['business_number']);
if (strlen($digits) === 10) {
2026-02-25 11:45:01 +09:00
$data['business_number'] = substr($digits, 0, 3).'-'.substr($digits, 3, 2).'-'.substr($digits, 5);
}
}
$address = trim($data['address'] ?? '');
return [
'name' => trim($data['company_name'] ?? ''),
'bizNo' => trim($data['business_number'] ?? ''),
'ceo' => trim($data['ceo_name'] ?? ''),
'contact' => trim($data['contact_phone'] ?? ''),
'email' => trim($data['email'] ?? ''),
'address' => $address,
'type' => trim($data['biz_type'] ?? ''),
'category' => trim($data['biz_item'] ?? ''),
];
}
}