diff --git a/app/Http/Controllers/Finance/TradingPartnerController.php b/app/Http/Controllers/Finance/TradingPartnerController.php index 8641b078..1e7c3fc6 100644 --- a/app/Http/Controllers/Finance/TradingPartnerController.php +++ b/app/Http/Controllers/Finance/TradingPartnerController.php @@ -4,6 +4,7 @@ use App\Http\Controllers\Controller; use App\Models\Finance\TradingPartner; +use App\Services\TradingPartnerOcrService; use Illuminate\Http\JsonResponse; use Illuminate\Http\Request; @@ -164,6 +165,24 @@ public function update(Request $request, int $id): JsonResponse ]); } + public function ocr(Request $request, TradingPartnerOcrService $ocrService): JsonResponse + { + $request->validate([ + 'image' => 'required|string', + ]); + + try { + $result = $ocrService->extractFromImage($request->input('image')); + + return response()->json($result); + } catch (\RuntimeException $e) { + return response()->json([ + 'ok' => false, + 'message' => $e->getMessage(), + ], 500); + } + } + public function destroy(int $id): JsonResponse { $tenantId = session('selected_tenant_id', 1); diff --git a/app/Services/TradingPartnerOcrService.php b/app/Services/TradingPartnerOcrService.php new file mode 100644 index 00000000..fae06ac4 --- /dev/null +++ b/app/Services/TradingPartnerOcrService.php @@ -0,0 +1,311 @@ +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(); + + if (!$projectId) { + throw new \RuntimeException('Vertex AI 프로젝트 ID가 설정되지 않았습니다.'); + } + + $accessToken = $this->getAccessToken($config); + 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, [ + '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(), + ]); + 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; + } + } + + if (!$serviceAccountPath) { + Log::error('Service account file not found', ['tried_paths' => $possiblePaths]); + return null; + } + + $serviceAccount = json_decode(file_get_contents($serviceAccountPath), true); + if (!$serviceAccount) { + Log::error('Service account JSON parse failed'); + 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']); + if (!$privateKey) { + Log::error('Failed to load private key'); + return null; + } + + 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(); + return $data['access_token'] ?? null; + } + + Log::error('OAuth token request failed', [ + 'status' => $response->status(), + 'body' => $response->body(), + ]); + return null; + } catch (\Exception $e) { + Log::error('OAuth token request exception', ['error' => $e->getMessage()]); + return null; + } + } + + /** + * Base64 URL 인코딩 + */ + private function base64UrlEncode(string $data): string + { + return rtrim(strtr(base64_encode($data), '+/', '-_'), '='); + } + + /** + * 사업자등록증 OCR 프롬프트 생성 + */ + private function buildPrompt(): string + { + return << trim($data['company_name'] ?? ''), + 'bizNo' => trim($data['business_number'] ?? ''), + 'manager' => trim($data['ceo_name'] ?? ''), + 'contact' => trim($data['contact_phone'] ?? ''), + 'email' => trim($data['email'] ?? ''), + 'memo' => implode(' / ', $memoParts), + ]; + } +} diff --git a/resources/views/finance/partners.blade.php b/resources/views/finance/partners.blade.php index 4c721c4f..e6f9f5ea 100644 --- a/resources/views/finance/partners.blade.php +++ b/resources/views/finance/partners.blade.php @@ -47,6 +47,9 @@ const Mail = createIcon('mail'); const Truck = createIcon('truck'); const Hammer = createIcon('hammer'); +const Upload = createIcon('upload'); +const FileText = createIcon('file-text'); +const Loader = createIcon('loader'); function PartnersManagement() { const [partners, setPartners] = useState([]); @@ -61,6 +64,8 @@ function PartnersManagement() { const [modalMode, setModalMode] = useState('add'); const [editingItem, setEditingItem] = useState(null); const [saving, setSaving] = useState(false); + const [ocrLoading, setOcrLoading] = useState(false); + const fileInputRef = useRef(null); const csrfToken = document.querySelector('meta[name="csrf-token"]')?.getAttribute('content'); @@ -160,6 +165,50 @@ function PartnersManagement() { } }; + const handleOcrUpload = (file) => { + if (!file || !file.type.startsWith('image/')) { + alert('이미지 파일만 업로드 가능합니다.'); + return; + } + if (file.size > 10 * 1024 * 1024) { + alert('파일 크기는 10MB 이하만 가능합니다.'); + return; + } + setOcrLoading(true); + const reader = new FileReader(); + reader.onload = async (e) => { + try { + const res = await fetch('/finance/partners/ocr', { + method: 'POST', + headers: { 'Content-Type': 'application/json', 'X-CSRF-TOKEN': csrfToken }, + body: JSON.stringify({ image: e.target.result }), + }); + const data = await res.json(); + if (data.ok && data.data) { + const d = data.data; + setFormData(prev => ({ + ...prev, + name: d.name || prev.name, + bizNo: d.bizNo || prev.bizNo, + manager: d.manager || prev.manager, + contact: d.contact || prev.contact, + email: d.email || prev.email, + memo: d.memo || prev.memo, + })); + } else { + alert(data.message || 'OCR 처리에 실패했습니다.'); + } + } catch (err) { + console.error('OCR 실패:', err); + alert('OCR 처리에 실패했습니다.'); + } finally { + setOcrLoading(false); + if (fileInputRef.current) fileInputRef.current.value = ''; + } + }; + reader.readAsDataURL(file); + }; + const handleDownload = () => { const rows = [['거래처 관리'], [], ['거래처명', '유형', '분류', '사업자번호', '연락처', '이메일', '담당자', '상태'], ...filteredPartners.map(item => [item.name, item.type === 'vendor' ? '공급업체' : '프리랜서', item.category, item.bizNo, item.contact, item.email, item.manager, item.status === 'active' ? '활성' : '비활성'])]; @@ -259,6 +308,28 @@ function PartnersManagement() {

{modalMode === 'add' ? '거래처 등록' : '거래처 수정'}

+ {modalMode === 'add' && ( +
!ocrLoading && fileInputRef.current?.click()} + onDragOver={(e) => { e.preventDefault(); e.stopPropagation(); }} + onDrop={(e) => { e.preventDefault(); e.stopPropagation(); if (!ocrLoading && e.dataTransfer.files[0]) handleOcrUpload(e.dataTransfer.files[0]); }} + > + e.target.files[0] && handleOcrUpload(e.target.files[0])} /> + {ocrLoading ? ( +
+ +

AI가 사업자등록증을 분석 중입니다...

+
+ ) : ( +
+ +

사업자등록증 이미지를 드래그하거나 클릭하여 업로드하세요

+

자동으로 거래처 정보가 입력됩니다

+
+ )} +
+ )}
setFormData(prev => ({ ...prev, name: e.target.value }))} placeholder="거래처명" className="w-full px-3 py-2 border border-gray-300 rounded-lg" />
diff --git a/routes/web.php b/routes/web.php index 9b2c6be7..b7d81c52 100644 --- a/routes/web.php +++ b/routes/web.php @@ -1063,6 +1063,7 @@ Route::prefix('partners')->name('partners.')->group(function () { Route::get('/list', [\App\Http\Controllers\Finance\TradingPartnerController::class, 'index'])->name('list'); Route::post('/store', [\App\Http\Controllers\Finance\TradingPartnerController::class, 'store'])->name('store'); + Route::post('/ocr', [\App\Http\Controllers\Finance\TradingPartnerController::class, 'ocr'])->name('ocr'); Route::put('/{id}', [\App\Http\Controllers\Finance\TradingPartnerController::class, 'update'])->name('update'); Route::delete('/{id}', [\App\Http\Controllers\Finance\TradingPartnerController::class, 'destroy'])->name('destroy'); });