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();
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user