feat:거래처 등록 사업자등록증 OCR 기능 추가
- TradingPartnerOcrService 신규 생성 (Gemini Vision API 사업자등록증 OCR) - TradingPartnerController에 ocr() 메서드 추가 - partners 라우트 그룹에 OCR 엔드포인트 추가 - 거래처 등록 모달에 이미지 드래그앤드롭 업로드 UI 추가 Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -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);
|
||||
|
||||
311
app/Services/TradingPartnerOcrService.php
Normal file
311
app/Services/TradingPartnerOcrService.php
Normal file
@@ -0,0 +1,311 @@
|
||||
<?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();
|
||||
|
||||
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 <<<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) {
|
||||
$data['business_number'] = substr($digits, 0, 3) . '-' . substr($digits, 3, 2) . '-' . substr($digits, 5);
|
||||
}
|
||||
}
|
||||
|
||||
// memo: 주소 + 업태 + 종목 조합
|
||||
$memoParts = [];
|
||||
$address = trim($data['address'] ?? '');
|
||||
$bizType = trim($data['biz_type'] ?? '');
|
||||
$bizItem = trim($data['biz_item'] ?? '');
|
||||
|
||||
if ($address) {
|
||||
$memoParts[] = "[주소] {$address}";
|
||||
}
|
||||
if ($bizType) {
|
||||
$memoParts[] = "[업태] {$bizType}";
|
||||
}
|
||||
if ($bizItem) {
|
||||
$memoParts[] = "[종목] {$bizItem}";
|
||||
}
|
||||
|
||||
return [
|
||||
'name' => 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),
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -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() {
|
||||
<h3 className="text-lg font-bold text-gray-900">{modalMode === 'add' ? '거래처 등록' : '거래처 수정'}</h3>
|
||||
<button onClick={() => setShowModal(false)} className="p-1 hover:bg-gray-100 rounded-lg"><X className="w-5 h-5 text-gray-500" /></button>
|
||||
</div>
|
||||
{modalMode === 'add' && (
|
||||
<div
|
||||
className={`mb-4 border-2 border-dashed rounded-xl p-6 text-center cursor-pointer transition-colors ${ocrLoading ? 'border-blue-400 bg-blue-50' : 'border-gray-300 hover:border-blue-400 hover:bg-blue-50'}`}
|
||||
onClick={() => !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]); }}
|
||||
>
|
||||
<input ref={fileInputRef} type="file" accept="image/*" className="hidden" onChange={(e) => e.target.files[0] && handleOcrUpload(e.target.files[0])} />
|
||||
{ocrLoading ? (
|
||||
<div className="flex flex-col items-center gap-2">
|
||||
<svg className="animate-spin h-8 w-8 text-blue-500" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24"><circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4"></circle><path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path></svg>
|
||||
<p className="text-sm text-blue-600 font-medium">AI가 사업자등록증을 분석 중입니다...</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex flex-col items-center gap-2">
|
||||
<FileText className="w-8 h-8 text-gray-400" />
|
||||
<p className="text-sm text-gray-600 font-medium">사업자등록증 이미지를 드래그하거나 클릭하여 업로드하세요</p>
|
||||
<p className="text-xs text-gray-400">자동으로 거래처 정보가 입력됩니다</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
<div className="space-y-4">
|
||||
<div><label className="block text-sm font-medium text-gray-700 mb-1">거래처명 *</label><input type="text" value={formData.name} onChange={(e) => setFormData(prev => ({ ...prev, name: e.target.value }))} placeholder="거래처명" className="w-full px-3 py-2 border border-gray-300 rounded-lg" /></div>
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user