feat(mng): 사업자등록증 OCR 기능 구현

## 주요 변경사항
- BizCertController: 내부 API (OCR, CRUD)
- BizCertOcrService: Claude Vision API 연동, Tesseract.js 지원
- BizCert 모델 및 FormRequest 추가
- config/services.php에 Claude API 설정 추가

## 프론트엔드
- business-ocr.blade.php: layouts.app 레이아웃 적용
- JS/AI 토글 모드 (Tesseract.js / Claude Vision)
- 이미지 전처리 추가 (그레이스케일, 대비 강화, 이진화)
- SweetAlert2 연동 (토스트, 삭제 확인)

## API 엔드포인트
- POST /api/biz-cert/ocr - OCR 처리
- GET /api/biz-cert - 목록 조회
- POST /api/biz-cert - 저장
- DELETE /api/biz-cert/{id} - 삭제

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
2025-12-16 01:56:49 +09:00
parent 1d4725e464
commit 534ffcfbc0
7 changed files with 1151 additions and 44 deletions

View File

@@ -0,0 +1,89 @@
<?php
namespace App\Http\Controllers\Api;
use App\Http\Controllers\Controller;
use App\Http\Requests\BizCertStoreRequest;
use App\Services\BizCertOcrService;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
/**
* 사업자등록증 OCR API 컨트롤러
*/
class BizCertController extends Controller
{
public function __construct(
private BizCertOcrService $service
) {}
/**
* Claude Vision OCR 처리
*
* POST /api/biz-cert/ocr
*/
public function ocr(Request $request): JsonResponse
{
$request->validate([
'image' => 'required|string',
'raw_text' => 'nullable|string',
]);
$result = $this->service->processWithClaude(
$request->input('image'),
$request->input('raw_text')
);
if (! $result['ok']) {
return response()->json($result, 400);
}
return response()->json($result);
}
/**
* 저장된 목록 조회
*
* GET /api/biz-cert
*/
public function index(): JsonResponse
{
$list = $this->service->list();
return response()->json([
'ok' => true,
'data' => $list,
]);
}
/**
* 사업자등록증 데이터 저장
*
* POST /api/biz-cert
*/
public function store(BizCertStoreRequest $request): JsonResponse
{
$bizCert = $this->service->store($request->validated());
return response()->json([
'ok' => true,
'message' => '저장되었습니다.',
'data' => $bizCert,
]);
}
/**
* 삭제
*
* DELETE /api/biz-cert/{id}
*/
public function destroy(int $id): JsonResponse
{
$this->service->delete($id);
return response()->json([
'ok' => true,
'message' => '삭제되었습니다.',
]);
}
}

View File

@@ -0,0 +1,37 @@
<?php
namespace App\Http\Requests;
use Illuminate\Foundation\Http\FormRequest;
class BizCertStoreRequest extends FormRequest
{
public function authorize(): bool
{
return true;
}
public function rules(): array
{
return [
'biz_no' => 'required|string|max:12',
'company_name' => 'required|string|max:100',
'representative' => 'nullable|string|max:50',
'open_date' => 'nullable|date',
'address' => 'nullable|string|max:255',
'biz_type' => 'nullable|string|max:100',
'biz_item' => 'nullable|string|max:255',
'issue_date' => 'nullable|date',
'raw_text' => 'nullable|string',
'ocr_method' => 'nullable|string|max:20',
];
}
public function messages(): array
{
return [
'biz_no.required' => '사업자등록번호는 필수입니다.',
'company_name.required' => '상호명은 필수입니다.',
];
}
}

47
app/Models/BizCert.php Normal file
View File

@@ -0,0 +1,47 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
/**
* 사업자등록증 OCR 데이터 모델
*/
class BizCert extends Model
{
protected $table = 'biz_cert';
protected $fillable = [
'biz_no',
'company_name',
'representative',
'open_date',
'address',
'biz_type',
'biz_item',
'issue_date',
'raw_text',
'ocr_method',
];
protected function casts(): array
{
return [
'open_date' => 'date',
'issue_date' => 'date',
];
}
/**
* 사업자번호 포맷팅 (XXX-XX-XXXXX)
*/
public function getFormattedBizNoAttribute(): string
{
$no = preg_replace('/[^0-9]/', '', $this->biz_no);
if (strlen($no) === 10) {
return substr($no, 0, 3).'-'.substr($no, 3, 2).'-'.substr($no, 5, 5);
}
return $this->biz_no;
}
}

View File

@@ -0,0 +1,242 @@
<?php
namespace App\Services;
use App\Models\BizCert;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Facades\Log;
/**
* 사업자등록증 OCR 서비스
*/
class BizCertOcrService
{
private string $apiKey;
private string $apiUrl = 'https://api.anthropic.com/v1/messages';
public function __construct()
{
$this->apiKey = config('services.claude.api_key') ?? '';
}
/**
* Claude Vision API를 사용한 OCR 처리
*/
public function processWithClaude(string $imageBase64, ?string $rawText = null): array
{
if (empty($this->apiKey)) {
return [
'ok' => false,
'error' => 'Claude API 키가 설정되지 않았습니다.',
];
}
// 이미지 데이터 파싱
$imageData = $imageBase64;
$mediaType = 'image/png';
if (preg_match('/^data:image\/(\w+);base64,(.+)$/', $imageBase64, $matches)) {
$mediaType = 'image/'.$matches[1];
$imageData = $matches[2];
}
// 프롬프트 생성
$promptText = $this->buildPrompt($rawText);
// API 요청
$requestBody = [
'model' => 'claude-3-haiku-20240307',
'max_tokens' => 4096,
'messages' => [
[
'role' => 'user',
'content' => [
[
'type' => 'image',
'source' => [
'type' => 'base64',
'media_type' => $mediaType,
'data' => $imageData,
],
],
[
'type' => 'text',
'text' => $promptText,
],
],
],
],
];
try {
$response = Http::withHeaders([
'x-api-key' => $this->apiKey,
'anthropic-version' => '2023-06-01',
])->post($this->apiUrl, $requestBody);
if (! $response->successful()) {
Log::error('Claude API Error', [
'status' => $response->status(),
'body' => $response->body(),
]);
return [
'ok' => false,
'error' => 'Claude API 호출 실패 (HTTP '.$response->status().')',
];
}
$apiResponse = $response->json();
$claudeText = $apiResponse['content'][0]['text'] ?? '';
if (empty($claudeText)) {
return [
'ok' => false,
'error' => 'Claude API 응답이 비어있습니다.',
];
}
// JSON 추출
$extractedData = $this->parseClaudeResponse($claudeText);
if (! $extractedData) {
return [
'ok' => false,
'error' => 'Claude 응답 JSON 파싱 실패',
'raw_response' => $claudeText,
];
}
return [
'ok' => true,
'data' => $extractedData,
'raw_response' => $claudeText,
];
} catch (\Exception $e) {
Log::error('Claude API Exception', ['message' => $e->getMessage()]);
return [
'ok' => false,
'error' => 'Claude API 호출 중 오류 발생: '.$e->getMessage(),
];
}
}
/**
* OCR 프롬프트 생성
*/
private function buildPrompt(?string $rawText = null): string
{
$prompt = "제공된 사업자등록증 이미지를 직접 분석하여 아래 필드를 정확하게 추출해주세요.\n\n";
if ($rawText) {
$prompt .= "참고: OCR 텍스트가 제공되었지만 부정확할 수 있으니, 이미지를 직접 읽어서 정확한 정보를 추출해주세요.\n";
$prompt .= "OCR 텍스트(참고용): {$rawText}\n\n";
}
$prompt .= <<<'EOT'
추출할 필드:
1. 사업자등록번호 (10자리 숫자, 형식: 000-00-00000)
2. 상호명 (법인명 또는 단체명)
3. 대표자명 (한글 이름)
4. 개업일자 (YYYY-MM-DD 형식)
5. 본점 소재지 (주소)
6. 업태
7. 종목
8. 발급일자 (YYYY-MM-DD 형식)
**중요 지침:**
- 이미지를 직접 읽어서 정확한 텍스트를 추출하세요.
- 사업자등록번호는 정확히 10자리 숫자여야 하며, 하이픈을 포함하여 000-00-00000 형식으로 반환하세요.
- 날짜는 YYYY-MM-DD 형식으로 변환하세요 (예: 2015년 06월 02일 → 2015-06-02).
- 대표자명은 2-4자의 한글 이름이어야 합니다.
- 이미지가 흐리거나 화질이 좋지 않아도 최대한 정확하게 읽어주세요.
- 특수문자나 공백을 정리해주세요.
**응답 형식 (JSON만 반환, 설명 없이):**
{
"biz_no": "123-45-67890",
"company_name": "주식회사 예시",
"representative": "홍길동",
"open_date": "2015-06-02",
"address": "서울특별시 강남구 ...",
"type": "제조업",
"item": "엘리베이터부장품",
"issue_date": "2024-01-15"
}
데이터를 찾을 수 없으면 빈 문자열("")로 반환하세요.
EOT;
return $prompt;
}
/**
* Claude 응답에서 JSON 추출
*/
private function parseClaudeResponse(string $claudeText): ?array
{
// JSON 부분만 추출
if (preg_match('/\{[^{}]*(?:\{[^{}]*\}[^{}]*)*\}/s', $claudeText, $matches)) {
$jsonText = $matches[0];
} else {
$jsonText = $claudeText;
}
$data = json_decode($jsonText, true);
if (! $data) {
return null;
}
// 필드명 정규화 (type -> biz_type, item -> biz_item)
return [
'biz_no' => $data['biz_no'] ?? '',
'company_name' => $data['company_name'] ?? '',
'representative' => $data['representative'] ?? '',
'open_date' => $data['open_date'] ?? '',
'address' => $data['address'] ?? '',
'biz_type' => $data['type'] ?? $data['biz_type'] ?? '',
'biz_item' => $data['item'] ?? $data['biz_item'] ?? '',
'issue_date' => $data['issue_date'] ?? '',
];
}
/**
* 사업자등록증 데이터 저장
*/
public function store(array $data): BizCert
{
return BizCert::create([
'biz_no' => preg_replace('/[^0-9]/', '', $data['biz_no'] ?? ''),
'company_name' => $data['company_name'] ?? '',
'representative' => $data['representative'] ?? null,
'open_date' => $data['open_date'] ?: null,
'address' => $data['address'] ?? null,
'biz_type' => $data['biz_type'] ?? null,
'biz_item' => $data['biz_item'] ?? null,
'issue_date' => $data['issue_date'] ?: null,
'raw_text' => $data['raw_text'] ?? null,
'ocr_method' => $data['ocr_method'] ?? 'claude',
]);
}
/**
* 목록 조회
*/
public function list(): \Illuminate\Database\Eloquent\Collection
{
return BizCert::orderBy('created_at', 'desc')->get();
}
/**
* 삭제
*/
public function delete(int $id): bool
{
$bizCert = BizCert::findOrFail($id);
return $bizCert->delete();
}
}

View File

@@ -40,4 +40,8 @@
'project_id' => env('GEMINI_PROJECT_ID', 'codebridge-chatbot'),
],
'claude' => [
'api_key' => env('CLAUDE_API_KEY'),
],
];

View File

@@ -1,62 +1,714 @@
@extends('layouts.presentation')
@extends('layouts.app')
@section('title', '사업자등록증 OCR')
@push('styles')
<style>
.placeholder-container { min-height: 70vh; display: flex; flex-direction: column; align-items: center; justify-content: center; }
.placeholder-icon { width: 5rem; height: 5rem; margin-bottom: 2rem; opacity: 0.6; color: #7c3aed; }
.placeholder-title { font-size: 2rem; font-weight: 700; color: #7c3aed; margin-bottom: 1rem; }
.placeholder-subtitle { font-size: 1.25rem; color: #64748b; max-width: 500px; text-align: center; line-height: 1.8; }
.placeholder-badge { margin-top: 2rem; padding: 0.5rem 1.5rem; background: linear-gradient(135deg, #8b5cf6, #7c3aed); color: white; border-radius: 9999px; font-weight: 600; font-size: 0.875rem; }
.feature-icon { width: 1.25rem; height: 1.25rem; color: #7c3aed; }
.ocr-row { display: grid; grid-template-columns: 1fr 1fr; gap: 24px; }
@media (max-width: 1024px) { .ocr-row { grid-template-columns: 1fr; } }
.preview-section, .form-section {
background: white;
border-radius: 12px;
padding: 24px;
box-shadow: 0 1px 3px rgba(0,0,0,0.1);
}
.drop-zone {
border: 2px dashed #d1d5db;
border-radius: 12px;
padding: 40px 20px;
text-align: center;
transition: all 0.2s;
cursor: pointer;
background: #f9fafb;
}
.drop-zone:hover, .drop-zone.dragover {
border-color: #3b82f6;
background: #eff6ff;
}
.drop-zone-icon { width: 48px; height: 48px; margin: 0 auto 12px; color: #9ca3af; }
.drop-zone.dragover .drop-zone-icon { color: #3b82f6; }
#preview-image {
max-width: 100%;
border-radius: 8px;
margin-top: 16px;
display: none;
}
.raw-text {
font-family: 'Fira Code', monospace;
font-size: 12px;
background: #f1f5f9;
padding: 12px;
border-radius: 8px;
max-height: 200px;
overflow-y: auto;
white-space: pre-wrap;
margin-top: 16px;
display: none;
}
.toggle-wrapper {
display: flex;
align-items: center;
gap: 12px;
padding: 12px 16px;
background: white;
border-radius: 10px;
margin-bottom: 16px;
box-shadow: 0 1px 3px rgba(0,0,0,0.1);
}
.toggle-switch {
position: relative;
width: 100px;
height: 32px;
}
.toggle-switch input { opacity: 0; width: 0; height: 0; }
.toggle-slider {
position: absolute;
inset: 0;
background: #64748b;
border-radius: 16px;
cursor: pointer;
transition: 0.3s;
}
.toggle-slider:before {
content: 'JS';
position: absolute;
height: 26px;
width: 46px;
left: 3px;
bottom: 3px;
background: white;
border-radius: 13px;
transition: 0.3s;
display: flex;
align-items: center;
justify-content: center;
font-size: 11px;
font-weight: 600;
color: #64748b;
}
.toggle-switch input:checked + .toggle-slider { background: #3b82f6; }
.toggle-switch input:checked + .toggle-slider:before {
transform: translateX(48px);
content: 'AI';
color: #3b82f6;
}
.status-badge {
display: inline-flex;
align-items: center;
gap: 6px;
padding: 6px 12px;
border-radius: 20px;
font-size: 13px;
font-weight: 500;
}
.status-waiting { background: #f1f5f9; color: #64748b; }
.status-processing { background: #dbeafe; color: #1d4ed8; }
.status-completed { background: #dcfce7; color: #16a34a; }
.status-error { background: #fee2e2; color: #dc2626; }
.form-group { margin-bottom: 16px; }
.form-group label {
display: block;
font-size: 13px;
font-weight: 600;
color: #374151;
margin-bottom: 6px;
}
.ocr-form-control {
width: 100%;
padding: 10px 14px;
border: 1px solid #e5e7eb;
border-radius: 8px;
font-size: 14px;
transition: all 0.2s;
}
.ocr-form-control:focus {
outline: none;
border-color: #3b82f6;
box-shadow: 0 0 0 3px rgba(59, 130, 246, 0.1);
}
.ocr-form-control.auto-filled {
background: #fef3c7;
border-color: #f59e0b;
}
.btn-group { display: flex; gap: 12px; margin-top: 24px; }
.saved-list {
margin-top: 24px;
background: white;
border-radius: 12px;
padding: 24px;
box-shadow: 0 1px 3px rgba(0,0,0,0.1);
}
.saved-list h3 {
font-size: 18px;
font-weight: 700;
margin-bottom: 16px;
color: #1f2937;
}
.saved-list table {
width: 100%;
border-collapse: collapse;
}
.saved-list th, .saved-list td {
padding: 12px;
text-align: left;
border-bottom: 1px solid #e5e7eb;
}
.saved-list th {
background: #f9fafb;
font-weight: 600;
font-size: 13px;
color: #6b7280;
}
.saved-list td { font-size: 14px; }
.spinner {
width: 16px;
height: 16px;
border: 2px solid #fff;
border-top-color: transparent;
border-radius: 50%;
animation: spin 0.8s linear infinite;
display: inline-block;
}
@keyframes spin { to { transform: rotate(360deg); } }
</style>
@endpush
@section('content')
<div class="min-h-screen bg-gradient-to-br from-violet-50 to-purple-100">
<div class="container mx-auto px-4 py-12">
<div class="placeholder-container">
<svg class="placeholder-icon" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" />
</svg>
<h1 class="placeholder-title">사업자등록증 OCR</h1>
<p class="placeholder-subtitle">
사업자등록증 이미지를 업로드하면 AI가 자동으로 텍스트를 추출하고
사업자 정보를 구조화된 데이터로 변환합니다.
</p>
<div class="placeholder-badge">AI/Automation</div>
<div class="max-w-7xl mx-auto">
<!-- 페이지 헤더 -->
<div class="mb-6">
<h1 class="text-2xl font-bold text-gray-800">사업자등록증 OCR</h1>
<p class="text-gray-600 mt-1">이미지나 PDF를 업로드하면 자동으로 정보를 추출합니다</p>
</div>
<!-- OCR 모드 토글 -->
<div class="toggle-wrapper">
<span class="text-sm font-semibold text-gray-700">OCR 모드:</span>
<label class="toggle-switch">
<input type="checkbox" id="mode-toggle">
<span class="toggle-slider"></span>
</label>
<span id="mode-description" class="text-sm text-gray-600">JavaScript OCR (Tesseract.js)</span>
</div>
<!-- OCR 영역 -->
<div class="ocr-row">
<div class="preview-section">
<div class="flex items-center justify-between mb-4">
<h2 class="text-lg font-bold text-gray-800">이미지 업로드</h2>
<span id="status" class="status-badge status-waiting">대기 </span>
</div>
<div class="drop-zone" id="drop-zone">
<input type="file" id="file-input" accept="image/*,.pdf" class="hidden">
<svg class="drop-zone-icon" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M4 16l4.586-4.586a2 2 0 012.828 0L16 16m-2-2l1.586-1.586a2 2 0 012.828 0L20 14m-6-6h.01M6 20h12a2 2 0 002-2V6a2 2 0 00-2-2H6a2 2 0 00-2 2v12a2 2 0 002 2z" />
</svg>
<p class="text-gray-600 font-medium">클릭하거나 파일을 드래그하세요</p>
<p class="text-gray-400 text-sm mt-1">PNG, JPG, PDF 지원</p>
</div>
<img id="preview-image" alt="Preview">
<canvas id="pdf-canvas" class="hidden"></canvas>
<div id="raw-text" class="raw-text"></div>
</div>
<div class="max-w-4xl mx-auto mt-12">
<div class="bg-white rounded-2xl shadow-lg p-8">
<h2 class="text-xl font-bold text-gray-800 mb-6 flex items-center">
<svg class="feature-icon mr-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10.325 4.317c.426-1.756 2.924-1.756 3.35 0a1.724 1.724 0 002.573 1.066c1.543-.94 3.31.826 2.37 2.37a1.724 1.724 0 001.065 2.572c1.756.426 1.756 2.924 0 3.35a1.724 1.724 0 00-1.066 2.573c.94 1.543-.826 3.31-2.37 2.37a1.724 1.724 0 00-2.572 1.065c-.426 1.756-2.924 1.756-3.35 0a1.724 1.724 0 00-2.573-1.066c-1.543.94-3.31-.826-2.37-2.37a1.724 1.724 0 00-1.065-2.572c-1.756-.426-1.756-2.924 0-3.35a1.724 1.724 0 001.066-2.573c-.94-1.543.826-3.31 2.37-2.37.996.608 2.296.07 2.572-1.065z" />
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" />
</svg>
예정 기능
</h2>
<div class="grid md:grid-cols-2 gap-6">
<div class="p-4 bg-violet-50 rounded-lg">
<h3 class="font-semibold text-violet-800 mb-2">이미지 업로드</h3>
<ul class="text-sm text-gray-600 space-y-1">
<li> 사업자등록증 사진 업로드</li>
<li> 드래그 드롭 지원</li>
<li> 다중 파일 처리</li>
</ul>
<div class="form-section">
<h2 class="text-lg font-bold text-gray-800 mb-4">추출된 정보</h2>
<form id="biz-form">
<div class="grid grid-cols-2 gap-4">
<div class="form-group col-span-2">
<label for="biz_no">사업자등록번호 *</label>
<input type="text" id="biz_no" name="biz_no" class="ocr-form-control" placeholder="000-00-00000">
</div>
<div class="p-4 bg-blue-50 rounded-lg">
<h3 class="font-semibold text-blue-800 mb-2">OCR 처리</h3>
<ul class="text-sm text-gray-600 space-y-1">
<li> Google Vision API 활용</li>
<li> 사업자번호 자동 추출</li>
<li> 상호명, 대표자명 인식</li>
</ul>
<div class="form-group col-span-2">
<label for="company_name">상호 *</label>
<input type="text" id="company_name" name="company_name" class="ocr-form-control" placeholder="상호명">
</div>
<div class="form-group">
<label for="representative">대표자</label>
<input type="text" id="representative" name="representative" class="ocr-form-control" placeholder="대표자명">
</div>
<div class="form-group">
<label for="open_date">개업일</label>
<input type="date" id="open_date" name="open_date" class="ocr-form-control">
</div>
<div class="form-group col-span-2">
<label for="address">주소</label>
<input type="text" id="address" name="address" class="ocr-form-control" placeholder="사업장 주소">
</div>
<div class="form-group">
<label for="biz_type">업태</label>
<input type="text" id="biz_type" name="biz_type" class="ocr-form-control" placeholder="업태">
</div>
<div class="form-group">
<label for="biz_item">종목</label>
<input type="text" id="biz_item" name="biz_item" class="ocr-form-control" placeholder="종목">
</div>
<div class="form-group">
<label for="issue_date">발급일</label>
<input type="date" id="issue_date" name="issue_date" class="ocr-form-control">
</div>
</div>
</div>
<input type="hidden" id="raw_text" name="raw_text">
<input type="hidden" id="ocr_method" name="ocr_method" value="tesseract">
<div class="btn-group">
<button type="button" id="save-btn" class="bg-green-600 hover:bg-green-700 text-white font-medium px-5 py-2.5 rounded-lg transition-colors inline-flex items-center gap-2" disabled>
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 13l4 4L19 7" />
</svg>
저장
</button>
<button type="button" id="reset-btn" class="bg-gray-200 hover:bg-gray-300 text-gray-800 font-medium px-5 py-2.5 rounded-lg transition-colors inline-flex items-center gap-2">
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15" />
</svg>
초기화
</button>
</div>
</form>
</div>
</div>
<!-- 저장된 목록 -->
<div class="saved-list">
<h3>저장된 사업자등록증</h3>
<table id="saved-table">
<thead>
<tr>
<th>사업자번호</th>
<th>상호</th>
<th>대표자</th>
<th>개업일</th>
<th>OCR</th>
<th>등록일</th>
<th></th>
</tr>
</thead>
<tbody id="saved-tbody"></tbody>
</table>
</div>
</div>
@endsection
@push('scripts')
<script src="https://cdnjs.cloudflare.com/ajax/libs/pdf.js/3.11.174/pdf.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/tesseract.js@5/dist/tesseract.min.js"></script>
<script>
document.addEventListener('DOMContentLoaded', function() {
const fileInput = document.getElementById('file-input');
const dropZone = document.getElementById('drop-zone');
const previewImage = document.getElementById('preview-image');
const pdfCanvas = document.getElementById('pdf-canvas');
const rawTextEl = document.getElementById('raw-text');
const statusEl = document.getElementById('status');
const modeToggle = document.getElementById('mode-toggle');
const modeDescription = document.getElementById('mode-description');
const saveBtn = document.getElementById('save-btn');
const resetBtn = document.getElementById('reset-btn');
const csrfToken = '{{ csrf_token() }}';
let currentImageBase64 = null;
// 모드 토글
modeToggle.addEventListener('change', function() {
if (this.checked) {
modeDescription.textContent = 'AI API (Claude Vision)';
document.getElementById('ocr_method').value = 'claude';
} else {
modeDescription.textContent = 'JavaScript OCR (Tesseract.js)';
document.getElementById('ocr_method').value = 'tesseract';
}
});
// 드래그 앤 드롭
dropZone.addEventListener('click', () => fileInput.click());
dropZone.addEventListener('dragover', (e) => {
e.preventDefault();
dropZone.classList.add('dragover');
});
dropZone.addEventListener('dragleave', () => dropZone.classList.remove('dragover'));
dropZone.addEventListener('drop', (e) => {
e.preventDefault();
dropZone.classList.remove('dragover');
if (e.dataTransfer.files.length) {
fileInput.files = e.dataTransfer.files;
handleFile(e.dataTransfer.files[0]);
}
});
fileInput.addEventListener('change', (e) => {
if (e.target.files.length) handleFile(e.target.files[0]);
});
// 파일 처리
async function handleFile(file) {
setStatus('processing', '처리 중...');
try {
if (file.type === 'application/pdf') {
currentImageBase64 = await convertPdfToImage(file);
} else {
currentImageBase64 = await fileToBase64(file);
}
previewImage.src = currentImageBase64;
previewImage.style.display = 'block';
if (modeToggle.checked) {
await processWithAI(currentImageBase64);
} else {
await processWithTesseract(currentImageBase64);
}
saveBtn.disabled = false;
setStatus('completed', '완료');
} catch (error) {
console.error(error);
setStatus('error', '오류 발생');
showToast('처리 중 오류가 발생했습니다: ' + error.message, 'error');
}
}
// PDF to Image
async function convertPdfToImage(file) {
const arrayBuffer = await file.arrayBuffer();
const pdf = await pdfjsLib.getDocument({ data: arrayBuffer }).promise;
const page = await pdf.getPage(1);
const scale = 2;
const viewport = page.getViewport({ scale });
pdfCanvas.width = viewport.width;
pdfCanvas.height = viewport.height;
await page.render({
canvasContext: pdfCanvas.getContext('2d'),
viewport: viewport
}).promise;
return pdfCanvas.toDataURL('image/png');
}
// File to Base64
function fileToBase64(file) {
return new Promise((resolve, reject) => {
const reader = new FileReader();
reader.onload = () => resolve(reader.result);
reader.onerror = reject;
reader.readAsDataURL(file);
});
}
// 이미지 전처리 (그레이스케일 + 대비 강화)
async function preprocessImage(imageBase64) {
return new Promise((resolve) => {
const img = new Image();
img.onload = () => {
const canvas = document.createElement('canvas');
const ctx = canvas.getContext('2d');
// 해상도 향상 (2배)
const scale = 2;
canvas.width = img.width * scale;
canvas.height = img.height * scale;
ctx.drawImage(img, 0, 0, canvas.width, canvas.height);
// 이미지 데이터 가져오기
const imageData = ctx.getImageData(0, 0, canvas.width, canvas.height);
const data = imageData.data;
// 그레이스케일 + 대비 강화
for (let i = 0; i < data.length; i += 4) {
// 그레이스케일
const gray = data[i] * 0.299 + data[i + 1] * 0.587 + data[i + 2] * 0.114;
// 대비 강화 (factor 1.5)
const factor = 1.5;
const adjusted = Math.min(255, Math.max(0, (gray - 128) * factor + 128));
// 이진화 (임계값 150)
const binary = adjusted > 150 ? 255 : 0;
data[i] = binary;
data[i + 1] = binary;
data[i + 2] = binary;
}
ctx.putImageData(imageData, 0, 0);
resolve(canvas.toDataURL('image/png'));
};
img.src = imageBase64;
});
}
// Tesseract.js OCR
async function processWithTesseract(imageBase64) {
setStatus('processing', '이미지 전처리 중...');
// 이미지 전처리
const processedImage = await preprocessImage(imageBase64);
setStatus('processing', 'OCR 처리 중...');
const result = await Tesseract.recognize(processedImage, 'kor+eng', {
logger: m => {
if (m.status === 'recognizing text') {
const progress = Math.round(m.progress * 100);
setStatus('processing', `OCR ${progress}%`);
}
}
});
const text = result.data.text;
rawTextEl.textContent = text;
rawTextEl.style.display = 'block';
document.getElementById('raw_text').value = text;
fillFormFromText(text);
}
// AI API OCR
async function processWithAI(imageBase64) {
setStatus('processing', 'AI 분석 중...');
const response = await fetch('/api/biz-cert/ocr', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-CSRF-TOKEN': csrfToken
},
body: JSON.stringify({
image: imageBase64,
raw_text: null
})
});
const result = await response.json();
if (!result.ok) {
throw new Error(result.error || 'AI OCR 실패');
}
if (result.raw_response) {
rawTextEl.textContent = result.raw_response;
rawTextEl.style.display = 'block';
document.getElementById('raw_text').value = result.raw_response;
}
fillFormFromData(result.data);
}
// 폼 채우기 (텍스트 파싱)
function fillFormFromText(text) {
const data = parseBizCert(text);
fillFormFromData(data);
}
// 폼 채우기 (데이터)
function fillFormFromData(data) {
const fields = ['biz_no', 'company_name', 'representative', 'open_date', 'address', 'biz_type', 'biz_item', 'issue_date'];
fields.forEach(field => {
const el = document.getElementById(field);
let value = data[field] || data[field.replace('biz_', '')] || '';
if (field === 'biz_no') {
value = normalizeBizNo(value);
} else if (field === 'open_date' || field === 'issue_date') {
value = toDateISO(value);
}
if (value) {
el.value = value;
el.classList.add('auto-filled');
setTimeout(() => el.classList.remove('auto-filled'), 3000);
}
});
}
// 사업자번호 정규화
function normalizeBizNo(value) {
const digits = (value || '').replace(/\D/g, '');
if (digits.length === 10) {
return digits.slice(0, 3) + '-' + digits.slice(3, 5) + '-' + digits.slice(5);
}
return value || '';
}
// 날짜 ISO 변환
function toDateISO(dateStr) {
if (!dateStr) return '';
const patterns = [
/(\d{4})\s*[.\-\/]\s*(\d{1,2})\s*[.\-\/]\s*(\d{1,2})/,
/(\d{4})(\d{2})(\d{2})/
];
for (let p of patterns) {
const m = dateStr.match(p);
if (m) return `${m[1]}-${m[2].padStart(2, '0')}-${m[3].padStart(2, '0')}`;
}
return '';
}
// OCR 텍스트 파싱
function parseBizCert(text) {
const T = text || '';
return {
biz_no: (T.match(/(\d{3}[\-\s]?\d{2}[\-\s]?\d{5})/) || [])[1] || '',
company_name: (T.match(/(?:법인명|상호)[:\s]*([-힣\s]+)(?=\s*대표)/) || [])[1]?.trim() || '',
representative: (T.match(/대표자[:\s]*([-]{2,4})/) || [])[1] || '',
open_date: (T.match(/개업[^\d]*(\d{4}[\s년.\-]*\d{1,2}[\s월.\-]*\d{1,2})/) || [])[1] || '',
address: (T.match(/(?:소재지|주소)[:\s]*([-힣\s\d\-]+?)(?=\s*사업|$)/) || [])[1]?.trim() || '',
biz_type: (T.match(/업태[:\s]*([-힣\s]+?)(?=\s*종목|$)/) || [])[1]?.trim() || '',
biz_item: (T.match(/종목[:\s]*([-힣\s,]+)/) || [])[1]?.trim() || '',
issue_date: (T.match(/발급[^\d]*(\d{4}[\s년.\-]*\d{1,2}[\s월.\-]*\d{1,2})/) || [])[1] || ''
};
}
// 저장
saveBtn.addEventListener('click', async () => {
const formData = {
biz_no: document.getElementById('biz_no').value,
company_name: document.getElementById('company_name').value,
representative: document.getElementById('representative').value,
open_date: document.getElementById('open_date').value || null,
address: document.getElementById('address').value,
biz_type: document.getElementById('biz_type').value,
biz_item: document.getElementById('biz_item').value,
issue_date: document.getElementById('issue_date').value || null,
raw_text: document.getElementById('raw_text').value,
ocr_method: document.getElementById('ocr_method').value
};
if (!formData.biz_no || !formData.company_name) {
showToast('사업자등록번호와 상호는 필수입니다.', 'error');
return;
}
saveBtn.disabled = true;
saveBtn.innerHTML = '<span class="spinner"></span> 저장 중...';
try {
const response = await fetch('/api/biz-cert', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-CSRF-TOKEN': csrfToken
},
body: JSON.stringify(formData)
});
const result = await response.json();
if (result.ok) {
showToast('저장되었습니다.', 'success');
loadSavedList();
resetForm();
} else {
throw new Error(result.message || '저장 실패');
}
} catch (error) {
showToast('저장 중 오류: ' + error.message, 'error');
} finally {
saveBtn.disabled = false;
saveBtn.innerHTML = '<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 13l4 4L19 7" /></svg> 저장';
}
});
// 초기화
resetBtn.addEventListener('click', resetForm);
function resetForm() {
document.getElementById('biz-form').reset();
previewImage.style.display = 'none';
rawTextEl.style.display = 'none';
currentImageBase64 = null;
saveBtn.disabled = true;
setStatus('waiting', '대기 중');
}
// 저장 목록 로드
async function loadSavedList() {
try {
const response = await fetch('/api/biz-cert', {
headers: { 'X-CSRF-TOKEN': csrfToken }
});
const result = await response.json();
if (result.ok) {
renderSavedList(result.data);
}
} catch (error) {
console.error('목록 로드 실패:', error);
}
}
function renderSavedList(list) {
const tbody = document.getElementById('saved-tbody');
tbody.innerHTML = list.map(item => `
<tr>
<td>${formatBizNo(item.biz_no)}</td>
<td>${item.company_name}</td>
<td>${item.representative || '-'}</td>
<td>${item.open_date || '-'}</td>
<td><span class="text-xs px-2 py-1 rounded ${item.ocr_method === 'claude' ? 'bg-blue-100 text-blue-700' : 'bg-gray-100 text-gray-700'}">${item.ocr_method}</span></td>
<td>${new Date(item.created_at).toLocaleDateString('ko-KR')}</td>
<td><button class="text-xs px-3 py-1.5 bg-red-100 text-red-600 hover:bg-red-200 rounded-md transition-colors" onclick="deleteBizCert(${item.id}, '${item.company_name}')">삭제</button></td>
</tr>
`).join('');
}
function formatBizNo(no) {
const digits = (no || '').replace(/\D/g, '');
if (digits.length === 10) {
return digits.slice(0, 3) + '-' + digits.slice(3, 5) + '-' + digits.slice(5);
}
return no;
}
// 삭제
window.deleteBizCert = function(id, name) {
showDeleteConfirm(name, async () => {
try {
const response = await fetch(`/api/biz-cert/${id}`, {
method: 'DELETE',
headers: { 'X-CSRF-TOKEN': csrfToken }
});
const result = await response.json();
if (result.ok) {
showToast('삭제되었습니다.', 'success');
loadSavedList();
} else {
throw new Error(result.message || '삭제 실패');
}
} catch (error) {
showToast('삭제 중 오류: ' + error.message, 'error');
}
});
};
// 상태 표시
function setStatus(type, text) {
statusEl.className = 'status-badge status-' + type;
statusEl.textContent = text;
}
// 초기 로드
loadSavedList();
});
</script>
@endpush

View File

@@ -442,6 +442,26 @@
// AI 문의용 오류 보고서 생성
Route::get('/error-report', [ItemFieldController::class, 'generateErrorReport'])->name('errorReport');
// 시스템 필드 정의 관리 (마스터 데이터)
Route::prefix('system-definitions')->name('systemDefinitions.')->group(function () {
Route::get('/', [ItemFieldController::class, 'systemFieldDefinitions'])->name('index');
Route::post('/', [ItemFieldController::class, 'storeSystemFieldDefinition'])->name('store');
Route::put('/{id}', [ItemFieldController::class, 'updateSystemFieldDefinition'])->name('update');
Route::delete('/{id}', [ItemFieldController::class, 'destroySystemFieldDefinition'])->name('destroy');
Route::post('/reorder', [ItemFieldController::class, 'reorderSystemFieldDefinitions'])->name('reorder');
});
// 소스 테이블 관리
Route::prefix('source-tables')->name('sourceTables.')->group(function () {
Route::post('/', [ItemFieldController::class, 'storeSourceTable'])->name('store');
Route::delete('/{sourceTable}', [ItemFieldController::class, 'destroySourceTable'])->name('destroy');
Route::post('/{sourceTable}/sync-field-names', [ItemFieldController::class, 'syncSourceTableFieldNames'])->name('syncFieldNames');
});
// DB 테이블 조회 (등록 가능한 테이블 목록)
Route::get('/database-tables', [ItemFieldController::class, 'databaseTables'])->name('databaseTables');
Route::get('/database-tables/{table}/columns', [ItemFieldController::class, 'tableColumns'])->name('tableColumns');
});
/*
@@ -524,3 +544,19 @@
Route::middleware(['web', 'auth'])->prefix('gemini')->name('api.gemini.')->group(function () {
Route::get('/api-key', [GeminiController::class, 'getApiKey'])->name('api-key');
});
/*
|--------------------------------------------------------------------------
| 사업자등록증 OCR API
|--------------------------------------------------------------------------
|
| Lab > AI > 사업자등록증 OCR 기능
| Claude Vision API를 사용한 OCR 처리
|
*/
Route::middleware(['web', 'auth'])->prefix('biz-cert')->name('api.biz-cert.')->group(function () {
Route::post('/ocr', [\App\Http\Controllers\Api\BizCertController::class, 'ocr'])->name('ocr');
Route::get('/', [\App\Http\Controllers\Api\BizCertController::class, 'index'])->name('index');
Route::post('/', [\App\Http\Controllers\Api\BizCertController::class, 'store'])->name('store');
Route::delete('/{id}', [\App\Http\Controllers\Api\BizCertController::class, 'destroy'])->name('destroy');
});