feat:고객사 등록 사업자등록증 OCR 자동입력 기능 추가
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user