diff --git a/app/Http/Controllers/Finance/CustomerController.php b/app/Http/Controllers/Finance/CustomerController.php index 57eeb594..02ce0dbc 100644 --- a/app/Http/Controllers/Finance/CustomerController.php +++ b/app/Http/Controllers/Finance/CustomerController.php @@ -4,8 +4,10 @@ use App\Http\Controllers\Controller; use App\Models\Finance\Customer; +use App\Services\TradingPartnerOcrService; use Illuminate\Http\JsonResponse; use Illuminate\Http\Request; +use Illuminate\Support\Facades\Log; class CustomerController extends Controller { @@ -90,4 +92,78 @@ public function destroy(int $id): JsonResponse Customer::forTenant($tenantId)->findOrFail($id)->delete(); return response()->json(['success' => true, 'message' => '고객사가 삭제되었습니다.']); } + + public function ocr(Request $request, TradingPartnerOcrService $ocrService): JsonResponse + { + $request->validate([ + 'image' => [ + 'required', + 'string', + function ($attribute, $value, $fail) { + if (strlen($value) > 7 * 1024 * 1024) { + $fail('이미지 크기는 5MB 이하여야 합니다.'); + } + }, + ], + ]); + + try { + $result = $ocrService->extractFromImage($request->input('image')); + + // raw_response에서 원본 OCR 데이터 파싱 (address, biz_type 등 직접 접근) + $raw = json_decode($result['raw_response'], true) ?? []; + + // 고객사 폼 필드에 맞게 매핑 + $data = [ + 'name' => trim($raw['company_name'] ?? $result['data']['name'] ?? ''), + 'bizNo' => $result['data']['bizNo'] ?? '', + 'ceo' => trim($raw['ceo_name'] ?? $result['data']['manager'] ?? ''), + 'contact' => $result['data']['contact'] ?? '', + 'email' => $result['data']['email'] ?? '', + 'address' => trim($raw['address'] ?? ''), + 'industry' => $this->matchIndustry($raw['biz_type'] ?? '', $raw['biz_item'] ?? ''), + 'memo' => $this->buildCustomerMemo($raw), + ]; + + return response()->json(['ok' => true, 'data' => $data]); + } catch (\RuntimeException $e) { + return response()->json(['ok' => false, 'message' => $e->getMessage()], 500); + } catch (\Throwable $e) { + Log::error('고객사 OCR 예상치 못한 오류', ['error' => $e->getMessage()]); + return response()->json(['ok' => false, 'message' => 'OCR 처리 중 오류가 발생했습니다.'], 500); + } + } + + private function matchIndustry(string $bizType, string $bizItem): string + { + $text = $bizType . ' ' . $bizItem; + + $keywords = [ + 'IT/소프트웨어' => ['소프트웨어', 'IT', '정보통신', '전산', '컴퓨터', '인터넷', '데이터', '프로그램'], + '제조업' => ['제조', '생산', '가공', '조립'], + '서비스업' => ['서비스', '용역', '컨설팅', '대행'], + '유통업' => ['유통', '도매', '소매', '판매', '무역', '수출', '수입', '상업'], + '금융업' => ['금융', '보험', '은행', '증권', '투자'], + ]; + + foreach ($keywords as $industry => $kws) { + foreach ($kws as $kw) { + if (mb_strpos($text, $kw) !== false) { + return $industry; + } + } + } + + return '기타'; + } + + private function buildCustomerMemo(array $raw): string + { + $parts = []; + $bizType = trim($raw['biz_type'] ?? ''); + $bizItem = trim($raw['biz_item'] ?? ''); + if ($bizType) $parts[] = "[업태] {$bizType}"; + if ($bizItem) $parts[] = "[종목] {$bizItem}"; + return implode(' / ', $parts); + } } diff --git a/resources/views/finance/customers.blade.php b/resources/views/finance/customers.blade.php index 0c116d0d..7be02d23 100644 --- a/resources/views/finance/customers.blade.php +++ b/resources/views/finance/customers.blade.php @@ -45,6 +45,7 @@ const Phone = createIcon('phone'); const Mail = createIcon('mail'); const MapPin = createIcon('map-pin'); +const ScanLine = createIcon('scan-line'); function CustomersManagement() { const [customers, setCustomers] = useState([]); @@ -79,6 +80,10 @@ function CustomersManagement() { memo: '' }; const [formData, setFormData] = useState(initialFormState); + const [ocrLoading, setOcrLoading] = useState(false); + const [ocrHighlightFields, setOcrHighlightFields] = useState([]); + const ocrFileRef = useRef(null); + const ocrCls = (field) => ocrHighlightFields.includes(field) ? ' ring-2 ring-amber-400 bg-amber-50 transition-all' : ' transition-all'; const fetchCustomers = async () => { setLoading(true); @@ -167,6 +172,50 @@ function CustomersManagement() { const link = document.createElement('a'); link.href = URL.createObjectURL(blob); link.download = '고객사목록.csv'; link.click(); }; + const handleOcr = async (e) => { + const file = e.target.files[0]; + if (!file) return; + if (!['image/jpeg', 'image/png', 'image/gif', 'image/webp'].includes(file.type)) { + alert('JPEG, PNG, GIF, WebP 이미지만 업로드 가능합니다.'); return; + } + if (file.size > 5 * 1024 * 1024) { + alert('이미지 크기는 5MB 이하여야 합니다.'); return; + } + ocrFileRef.current.value = ''; + setOcrLoading(true); + const reader = new FileReader(); + reader.onload = async () => { + try { + const res = await fetch('/finance/customers/ocr', { + method: 'POST', + headers: { 'Content-Type': 'application/json', 'X-CSRF-TOKEN': csrfToken }, + body: JSON.stringify({ image: reader.result }), + }); + const data = await res.json(); + if (data.ok && data.data) { + const filled = []; + setFormData(prev => { + const updated = { ...prev }; + Object.keys(data.data).forEach(key => { + if (data.data[key]) { updated[key] = data.data[key]; filled.push(key); } + }); + return updated; + }); + setOcrHighlightFields(filled); + setTimeout(() => setOcrHighlightFields([]), 2000); + } else { + alert(data.message || 'OCR 인식에 실패했습니다.'); + } + } catch (err) { + console.error('OCR 실패:', err); + alert('OCR 처리에 실패했습니다.'); + } finally { + setOcrLoading(false); + } + }; + reader.readAsDataURL(file); + }; + const getGradeColor = (grade) => { const colors = { VIP: 'bg-purple-100 text-purple-700', Gold: 'bg-amber-100 text-amber-700', Silver: 'bg-gray-100 text-gray-700', Bronze: 'bg-orange-100 text-orange-700' }; return colors[grade] || 'bg-gray-100 text-gray-700'; @@ -260,30 +309,37 @@ function CustomersManagement() {

{modalMode === 'add' ? '고객사 등록' : '고객사 수정'}

- +
+ + + +
-
setFormData(prev => ({ ...prev, name: e.target.value }))} placeholder="(주)회사명" className="w-full px-3 py-2 border border-gray-300 rounded-lg" />
+
setFormData(prev => ({ ...prev, name: e.target.value }))} placeholder="(주)회사명" className={`w-full px-3 py-2 border border-gray-300 rounded-lg${ocrCls('name')}`} />
-
setFormData(prev => ({ ...prev, bizNo: e.target.value }))} placeholder="123-45-67890" className="w-full px-3 py-2 border border-gray-300 rounded-lg" />
-
setFormData(prev => ({ ...prev, ceo: e.target.value }))} placeholder="대표자명" className="w-full px-3 py-2 border border-gray-300 rounded-lg" />
+
setFormData(prev => ({ ...prev, bizNo: e.target.value }))} placeholder="123-45-67890" className={`w-full px-3 py-2 border border-gray-300 rounded-lg${ocrCls('bizNo')}`} />
+
setFormData(prev => ({ ...prev, ceo: e.target.value }))} placeholder="대표자명" className={`w-full px-3 py-2 border border-gray-300 rounded-lg${ocrCls('ceo')}`} />
-
+
-
setFormData(prev => ({ ...prev, contact: e.target.value }))} placeholder="02-1234-5678" className="w-full px-3 py-2 border border-gray-300 rounded-lg" />
-
setFormData(prev => ({ ...prev, email: e.target.value }))} placeholder="email@company.co.kr" className="w-full px-3 py-2 border border-gray-300 rounded-lg" />
+
setFormData(prev => ({ ...prev, contact: e.target.value }))} placeholder="02-1234-5678" className={`w-full px-3 py-2 border border-gray-300 rounded-lg${ocrCls('contact')}`} />
+
setFormData(prev => ({ ...prev, email: e.target.value }))} placeholder="email@company.co.kr" className={`w-full px-3 py-2 border border-gray-300 rounded-lg${ocrCls('email')}`} />
-
setFormData(prev => ({ ...prev, address: e.target.value }))} placeholder="주소" className="w-full px-3 py-2 border border-gray-300 rounded-lg" />
+
setFormData(prev => ({ ...prev, address: e.target.value }))} placeholder="주소" className={`w-full px-3 py-2 border border-gray-300 rounded-lg${ocrCls('address')}`} />
setFormData(prev => ({ ...prev, manager: e.target.value }))} placeholder="담당자명" className="w-full px-3 py-2 border border-gray-300 rounded-lg" />
setFormData(prev => ({ ...prev, managerPhone: e.target.value }))} placeholder="010-1234-5678" className="w-full px-3 py-2 border border-gray-300 rounded-lg" />
-
setFormData(prev => ({ ...prev, memo: e.target.value }))} placeholder="메모" className="w-full px-3 py-2 border border-gray-300 rounded-lg" />
+
setFormData(prev => ({ ...prev, memo: e.target.value }))} placeholder="메모" className={`w-full px-3 py-2 border border-gray-300 rounded-lg${ocrCls('memo')}`} />
diff --git a/routes/web.php b/routes/web.php index b71fee9c..a8f1fcb6 100644 --- a/routes/web.php +++ b/routes/web.php @@ -1087,6 +1087,7 @@ Route::prefix('customers')->name('customers.')->group(function () { Route::get('/list', [\App\Http\Controllers\Finance\CustomerController::class, 'index'])->name('list'); Route::post('/store', [\App\Http\Controllers\Finance\CustomerController::class, 'store'])->name('store'); + Route::post('/ocr', [\App\Http\Controllers\Finance\CustomerController::class, 'ocr'])->middleware('throttle:10,1')->name('ocr'); Route::put('/{id}', [\App\Http\Controllers\Finance\CustomerController::class, 'update'])->name('update'); Route::delete('/{id}', [\App\Http\Controllers\Finance\CustomerController::class, 'destroy'])->name('destroy'); });