|
|
|
|
@@ -1,714 +0,0 @@
|
|
|
|
|
@extends('layouts.app')
|
|
|
|
|
|
|
|
|
|
@section('title', '사업자등록증 OCR')
|
|
|
|
|
|
|
|
|
|
@push('styles')
|
|
|
|
|
<style>
|
|
|
|
|
.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="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="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="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>
|
|
|
|
|
|
|
|
|
|
<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
|