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:
89
app/Http/Controllers/Api/BizCertController.php
Normal file
89
app/Http/Controllers/Api/BizCertController.php
Normal 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' => '삭제되었습니다.',
|
||||
]);
|
||||
}
|
||||
}
|
||||
37
app/Http/Requests/BizCertStoreRequest.php
Normal file
37
app/Http/Requests/BizCertStoreRequest.php
Normal 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
47
app/Models/BizCert.php
Normal 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;
|
||||
}
|
||||
}
|
||||
242
app/Services/BizCertOcrService.php
Normal file
242
app/Services/BizCertOcrService.php
Normal 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();
|
||||
}
|
||||
}
|
||||
@@ -40,4 +40,8 @@
|
||||
'project_id' => env('GEMINI_PROJECT_ID', 'codebridge-chatbot'),
|
||||
],
|
||||
|
||||
'claude' => [
|
||||
'api_key' => env('CLAUDE_API_KEY'),
|
||||
],
|
||||
|
||||
];
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user