feat:고객사 등록 사업자등록증 OCR 자동입력 기능 추가

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
김보곤
2026-02-13 10:51:38 +09:00
parent db13550f38
commit 8a18244000
3 changed files with 142 additions and 9 deletions

View File

@@ -4,8 +4,10 @@
use App\Http\Controllers\Controller;
use App\Models\Finance\Customer;
use App\Services\TradingPartnerOcrService;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Log;
class CustomerController extends Controller
{
@@ -90,4 +92,78 @@ public function destroy(int $id): JsonResponse
Customer::forTenant($tenantId)->findOrFail($id)->delete();
return response()->json(['success' => true, 'message' => '고객사가 삭제되었습니다.']);
}
public function ocr(Request $request, TradingPartnerOcrService $ocrService): JsonResponse
{
$request->validate([
'image' => [
'required',
'string',
function ($attribute, $value, $fail) {
if (strlen($value) > 7 * 1024 * 1024) {
$fail('이미지 크기는 5MB 이하여야 합니다.');
}
},
],
]);
try {
$result = $ocrService->extractFromImage($request->input('image'));
// raw_response에서 원본 OCR 데이터 파싱 (address, biz_type 등 직접 접근)
$raw = json_decode($result['raw_response'], true) ?? [];
// 고객사 폼 필드에 맞게 매핑
$data = [
'name' => trim($raw['company_name'] ?? $result['data']['name'] ?? ''),
'bizNo' => $result['data']['bizNo'] ?? '',
'ceo' => trim($raw['ceo_name'] ?? $result['data']['manager'] ?? ''),
'contact' => $result['data']['contact'] ?? '',
'email' => $result['data']['email'] ?? '',
'address' => trim($raw['address'] ?? ''),
'industry' => $this->matchIndustry($raw['biz_type'] ?? '', $raw['biz_item'] ?? ''),
'memo' => $this->buildCustomerMemo($raw),
];
return response()->json(['ok' => true, 'data' => $data]);
} catch (\RuntimeException $e) {
return response()->json(['ok' => false, 'message' => $e->getMessage()], 500);
} catch (\Throwable $e) {
Log::error('고객사 OCR 예상치 못한 오류', ['error' => $e->getMessage()]);
return response()->json(['ok' => false, 'message' => 'OCR 처리 중 오류가 발생했습니다.'], 500);
}
}
private function matchIndustry(string $bizType, string $bizItem): string
{
$text = $bizType . ' ' . $bizItem;
$keywords = [
'IT/소프트웨어' => ['소프트웨어', 'IT', '정보통신', '전산', '컴퓨터', '인터넷', '데이터', '프로그램'],
'제조업' => ['제조', '생산', '가공', '조립'],
'서비스업' => ['서비스', '용역', '컨설팅', '대행'],
'유통업' => ['유통', '도매', '소매', '판매', '무역', '수출', '수입', '상업'],
'금융업' => ['금융', '보험', '은행', '증권', '투자'],
];
foreach ($keywords as $industry => $kws) {
foreach ($kws as $kw) {
if (mb_strpos($text, $kw) !== false) {
return $industry;
}
}
}
return '기타';
}
private function buildCustomerMemo(array $raw): string
{
$parts = [];
$bizType = trim($raw['biz_type'] ?? '');
$bizItem = trim($raw['biz_item'] ?? '');
if ($bizType) $parts[] = "[업태] {$bizType}";
if ($bizItem) $parts[] = "[종목] {$bizItem}";
return implode(' / ', $parts);
}
}

View File

@@ -45,6 +45,7 @@
const Phone = createIcon('phone');
const Mail = createIcon('mail');
const MapPin = createIcon('map-pin');
const ScanLine = createIcon('scan-line');
function CustomersManagement() {
const [customers, setCustomers] = useState([]);
@@ -79,6 +80,10 @@ function CustomersManagement() {
memo: ''
};
const [formData, setFormData] = useState(initialFormState);
const [ocrLoading, setOcrLoading] = useState(false);
const [ocrHighlightFields, setOcrHighlightFields] = useState([]);
const ocrFileRef = useRef(null);
const ocrCls = (field) => ocrHighlightFields.includes(field) ? ' ring-2 ring-amber-400 bg-amber-50 transition-all' : ' transition-all';
const fetchCustomers = async () => {
setLoading(true);
@@ -167,6 +172,50 @@ function CustomersManagement() {
const link = document.createElement('a'); link.href = URL.createObjectURL(blob); link.download = '고객사목록.csv'; link.click();
};
const handleOcr = async (e) => {
const file = e.target.files[0];
if (!file) return;
if (!['image/jpeg', 'image/png', 'image/gif', 'image/webp'].includes(file.type)) {
alert('JPEG, PNG, GIF, WebP 이미지만 업로드 가능합니다.'); return;
}
if (file.size > 5 * 1024 * 1024) {
alert('이미지 크기는 5MB 이하여야 합니다.'); return;
}
ocrFileRef.current.value = '';
setOcrLoading(true);
const reader = new FileReader();
reader.onload = async () => {
try {
const res = await fetch('/finance/customers/ocr', {
method: 'POST',
headers: { 'Content-Type': 'application/json', 'X-CSRF-TOKEN': csrfToken },
body: JSON.stringify({ image: reader.result }),
});
const data = await res.json();
if (data.ok && data.data) {
const filled = [];
setFormData(prev => {
const updated = { ...prev };
Object.keys(data.data).forEach(key => {
if (data.data[key]) { updated[key] = data.data[key]; filled.push(key); }
});
return updated;
});
setOcrHighlightFields(filled);
setTimeout(() => setOcrHighlightFields([]), 2000);
} else {
alert(data.message || 'OCR 인식에 실패했습니다.');
}
} catch (err) {
console.error('OCR 실패:', err);
alert('OCR 처리에 실패했습니다.');
} finally {
setOcrLoading(false);
}
};
reader.readAsDataURL(file);
};
const getGradeColor = (grade) => {
const colors = { VIP: 'bg-purple-100 text-purple-700', Gold: 'bg-amber-100 text-amber-700', Silver: 'bg-gray-100 text-gray-700', Bronze: 'bg-orange-100 text-orange-700' };
return colors[grade] || 'bg-gray-100 text-gray-700';
@@ -260,30 +309,37 @@ function CustomersManagement() {
<div className="bg-white rounded-xl p-6 w-full max-w-lg mx-4 max-h-[90vh] overflow-y-auto">
<div className="flex items-center justify-between mb-6">
<h3 className="text-lg font-bold text-gray-900">{modalMode === 'add' ? '고객사 등록' : '고객사 수정'}</h3>
<button onClick={() => setShowModal(false)} className="p-1 hover:bg-gray-100 rounded-lg"><X className="w-5 h-5 text-gray-500" /></button>
<div className="flex items-center gap-2">
<input type="file" ref={ocrFileRef} accept="image/*" onChange={handleOcr} className="hidden" />
<button onClick={() => ocrFileRef.current?.click()} disabled={ocrLoading} className="flex items-center gap-1.5 px-3 py-1.5 text-sm bg-amber-50 text-amber-700 border border-amber-300 rounded-lg hover:bg-amber-100 disabled:opacity-50">
{ocrLoading ? <svg className="animate-spin h-4 w-4" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24"><circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4"></circle><path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path></svg> : <ScanLine className="w-4 h-4" />}
{ocrLoading ? 'AI 분석중...' : '사업자등록증'}
</button>
<button onClick={() => setShowModal(false)} className="p-1 hover:bg-gray-100 rounded-lg"><X className="w-5 h-5 text-gray-500" /></button>
</div>
</div>
<div className="space-y-4">
<div><label className="block text-sm font-medium text-gray-700 mb-1">회사명 *</label><input type="text" value={formData.name} onChange={(e) => setFormData(prev => ({ ...prev, name: e.target.value }))} placeholder="(주)회사명" className="w-full px-3 py-2 border border-gray-300 rounded-lg" /></div>
<div><label className="block text-sm font-medium text-gray-700 mb-1">회사명 *</label><input type="text" value={formData.name} onChange={(e) => setFormData(prev => ({ ...prev, name: e.target.value }))} placeholder="(주)회사명" className={`w-full px-3 py-2 border border-gray-300 rounded-lg${ocrCls('name')}`} /></div>
<div className="grid grid-cols-2 gap-4">
<div><label className="block text-sm font-medium text-gray-700 mb-1">사업자번호</label><input type="text" value={formData.bizNo} onChange={(e) => setFormData(prev => ({ ...prev, bizNo: e.target.value }))} placeholder="123-45-67890" className="w-full px-3 py-2 border border-gray-300 rounded-lg" /></div>
<div><label className="block text-sm font-medium text-gray-700 mb-1">대표자</label><input type="text" value={formData.ceo} onChange={(e) => setFormData(prev => ({ ...prev, ceo: e.target.value }))} placeholder="대표자명" className="w-full px-3 py-2 border border-gray-300 rounded-lg" /></div>
<div><label className="block text-sm font-medium text-gray-700 mb-1">사업자번호</label><input type="text" value={formData.bizNo} onChange={(e) => setFormData(prev => ({ ...prev, bizNo: e.target.value }))} placeholder="123-45-67890" className={`w-full px-3 py-2 border border-gray-300 rounded-lg${ocrCls('bizNo')}`} /></div>
<div><label className="block text-sm font-medium text-gray-700 mb-1">대표자</label><input type="text" value={formData.ceo} onChange={(e) => setFormData(prev => ({ ...prev, ceo: e.target.value }))} placeholder="대표자명" className={`w-full px-3 py-2 border border-gray-300 rounded-lg${ocrCls('ceo')}`} /></div>
</div>
<div className="grid grid-cols-2 gap-4">
<div><label className="block text-sm font-medium text-gray-700 mb-1">업종</label><select value={formData.industry} onChange={(e) => setFormData(prev => ({ ...prev, industry: e.target.value }))} className="w-full px-3 py-2 border border-gray-300 rounded-lg">{industries.map(i => <option key={i} value={i}>{i}</option>)}</select></div>
<div><label className="block text-sm font-medium text-gray-700 mb-1">업종</label><select value={formData.industry} onChange={(e) => setFormData(prev => ({ ...prev, industry: e.target.value }))} className={`w-full px-3 py-2 border border-gray-300 rounded-lg${ocrCls('industry')}`}>{industries.map(i => <option key={i} value={i}>{i}</option>)}</select></div>
<div><label className="block text-sm font-medium text-gray-700 mb-1">등급</label><select value={formData.grade} onChange={(e) => setFormData(prev => ({ ...prev, grade: e.target.value }))} className="w-full px-3 py-2 border border-gray-300 rounded-lg">{grades.map(g => <option key={g} value={g}>{g}</option>)}</select></div>
</div>
<div className="grid grid-cols-2 gap-4">
<div><label className="block text-sm font-medium text-gray-700 mb-1">연락처</label><input type="text" value={formData.contact} onChange={(e) => setFormData(prev => ({ ...prev, contact: e.target.value }))} placeholder="02-1234-5678" className="w-full px-3 py-2 border border-gray-300 rounded-lg" /></div>
<div><label className="block text-sm font-medium text-gray-700 mb-1">이메일</label><input type="email" value={formData.email} onChange={(e) => setFormData(prev => ({ ...prev, email: e.target.value }))} placeholder="email@company.co.kr" className="w-full px-3 py-2 border border-gray-300 rounded-lg" /></div>
<div><label className="block text-sm font-medium text-gray-700 mb-1">연락처</label><input type="text" value={formData.contact} onChange={(e) => setFormData(prev => ({ ...prev, contact: e.target.value }))} placeholder="02-1234-5678" className={`w-full px-3 py-2 border border-gray-300 rounded-lg${ocrCls('contact')}`} /></div>
<div><label className="block text-sm font-medium text-gray-700 mb-1">이메일</label><input type="email" value={formData.email} onChange={(e) => setFormData(prev => ({ ...prev, email: e.target.value }))} placeholder="email@company.co.kr" className={`w-full px-3 py-2 border border-gray-300 rounded-lg${ocrCls('email')}`} /></div>
</div>
<div><label className="block text-sm font-medium text-gray-700 mb-1">주소</label><input type="text" value={formData.address} onChange={(e) => setFormData(prev => ({ ...prev, address: e.target.value }))} placeholder="주소" className="w-full px-3 py-2 border border-gray-300 rounded-lg" /></div>
<div><label className="block text-sm font-medium text-gray-700 mb-1">주소</label><input type="text" value={formData.address} onChange={(e) => setFormData(prev => ({ ...prev, address: e.target.value }))} placeholder="주소" className={`w-full px-3 py-2 border border-gray-300 rounded-lg${ocrCls('address')}`} /></div>
<div className="grid grid-cols-2 gap-4">
<div><label className="block text-sm font-medium text-gray-700 mb-1">담당자</label><input type="text" value={formData.manager} onChange={(e) => setFormData(prev => ({ ...prev, manager: e.target.value }))} placeholder="담당자명" className="w-full px-3 py-2 border border-gray-300 rounded-lg" /></div>
<div><label className="block text-sm font-medium text-gray-700 mb-1">담당자 연락처</label><input type="text" value={formData.managerPhone} onChange={(e) => setFormData(prev => ({ ...prev, managerPhone: e.target.value }))} placeholder="010-1234-5678" className="w-full px-3 py-2 border border-gray-300 rounded-lg" /></div>
</div>
<div className="grid grid-cols-2 gap-4">
<div><label className="block text-sm font-medium text-gray-700 mb-1">상태</label><select value={formData.status} onChange={(e) => setFormData(prev => ({ ...prev, status: e.target.value }))} className="w-full px-3 py-2 border border-gray-300 rounded-lg"><option value="active">활성</option><option value="inactive">비활성</option></select></div>
<div><label className="block text-sm font-medium text-gray-700 mb-1">메모</label><input type="text" value={formData.memo} onChange={(e) => setFormData(prev => ({ ...prev, memo: e.target.value }))} placeholder="메모" className="w-full px-3 py-2 border border-gray-300 rounded-lg" /></div>
<div><label className="block text-sm font-medium text-gray-700 mb-1">메모</label><input type="text" value={formData.memo} onChange={(e) => setFormData(prev => ({ ...prev, memo: e.target.value }))} placeholder="메모" className={`w-full px-3 py-2 border border-gray-300 rounded-lg${ocrCls('memo')}`} /></div>
</div>
</div>
<div className="flex gap-3 mt-6">

View File

@@ -1087,6 +1087,7 @@
Route::prefix('customers')->name('customers.')->group(function () {
Route::get('/list', [\App\Http\Controllers\Finance\CustomerController::class, 'index'])->name('list');
Route::post('/store', [\App\Http\Controllers\Finance\CustomerController::class, 'store'])->name('store');
Route::post('/ocr', [\App\Http\Controllers\Finance\CustomerController::class, 'ocr'])->middleware('throttle:10,1')->name('ocr');
Route::put('/{id}', [\App\Http\Controllers\Finance\CustomerController::class, 'update'])->name('update');
Route::delete('/{id}', [\App\Http\Controllers\Finance\CustomerController::class, 'destroy'])->name('destroy');
});