초기 커밋: 5130 레거시 시스템
- URL 하드코딩 → .env APP_URL 기반 동적 URL로 변경 - DB 연결 하드코딩 → .env 기반으로 변경 - MySQL strict mode DATE 오류 수정
This commit is contained in:
681
ocr/README.md
Normal file
681
ocr/README.md
Normal file
@@ -0,0 +1,681 @@
|
||||
# 사업자등록증 OCR 시스템
|
||||
|
||||
브라우저 기반 OCR을 사용하여 사업자등록증 이미지/PDF를 자동으로 인식하고 데이터를 추출하는 시스템입니다.
|
||||
|
||||
## 📋 기능
|
||||
|
||||
- ✅ 사업자등록증 이미지/PDF 업로드
|
||||
- ✅ **듀얼 OCR 모드**: JavaScript OCR + AI API (Claude) 선택 가능 ⭐NEW
|
||||
- ✅ 브라우저 기반 OCR (Tesseract.js)
|
||||
- ✅ AI 기반 OCR (Claude 3 Haiku with Vision) ⭐NEW
|
||||
- ✅ 자동 정보 추출 및 폼 자동입력
|
||||
- ✅ 사업자번호 유효성 검증 (체크섬)
|
||||
- ✅ 데이터 저장 및 목록 관리
|
||||
- ✅ 검색 및 페이징
|
||||
- ✅ 상세보기 모달
|
||||
|
||||
## 📁 파일 구조
|
||||
|
||||
```
|
||||
ocr/
|
||||
├── index.php # OCR 메인 페이지 (업로드 및 인식)
|
||||
├── claude_api.php # Claude AI API 호출 백엔드 ⭐NEW
|
||||
├── save_biz.php # 데이터 저장 API
|
||||
├── list.php # 목록 페이지
|
||||
├── view_detail.php # 상세정보 조회 API
|
||||
├── test_api.php # API 키 테스트 스크립트 (개발용)
|
||||
├── test_models.php # Claude 모델 테스트 스크립트 (개발용)
|
||||
├── dev.md # 개발 참고자료
|
||||
└── README.md # 사용 설명서
|
||||
```
|
||||
|
||||
## 🗄️ 데이터베이스
|
||||
|
||||
테이블: `biz_cert`
|
||||
|
||||
```sql
|
||||
CREATE TABLE biz_cert (
|
||||
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
|
||||
biz_no VARCHAR(20) NOT NULL, -- 000-00-00000
|
||||
company_name VARCHAR(255) NOT NULL,
|
||||
representative VARCHAR(100) NOT NULL,
|
||||
open_date DATE NULL,
|
||||
address TEXT NULL,
|
||||
type VARCHAR(255) NULL,
|
||||
item VARCHAR(255) NULL,
|
||||
issue_date DATE NULL,
|
||||
raw_text LONGTEXT NULL, -- 원문 텍스트(디버그용)
|
||||
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
PRIMARY KEY (id),
|
||||
KEY idx_biz_no (biz_no)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
||||
```
|
||||
|
||||
## 🤖 듀얼 OCR 모드 (NEW!)
|
||||
|
||||
시스템은 두 가지 OCR 방식을 제공하며, 사용자가 토글 버튼으로 선택할 수 있습니다.
|
||||
|
||||
### OCR 모드 선택
|
||||
|
||||
파일 업로드 전, **OCR 모드 토글 버튼**을 통해 원하는 방식을 선택하세요:
|
||||
|
||||
| 모드 | 설명 | 처리 위치 | 속도 | 정확도 | 비용 |
|
||||
|------|------|-----------|------|--------|------|
|
||||
| **JS 사용** | Tesseract.js 브라우저 OCR | 클라이언트 | 보통 (15-30초) | 80-90% | 무료 |
|
||||
| **AI API** | Claude 3 Haiku Vision API | 서버 | 빠름 (5-10초) | 90-95% | 유료 |
|
||||
|
||||
### JavaScript OCR 모드 (기본)
|
||||
|
||||
**특징:**
|
||||
- ✅ 완전 무료
|
||||
- ✅ 서버 부하 없음
|
||||
- ✅ 오프라인 동작 가능 (언어팩 캐시 후)
|
||||
- ✅ 데이터 프라이버시 (서버 전송 없음)
|
||||
- ⚠️ 처리 속도가 느림
|
||||
- ⚠️ 복잡한 레이아웃에서 정확도 하락
|
||||
|
||||
**처리 과정:**
|
||||
```
|
||||
1. 이미지 전처리 (그레이스케일, 대비, 이진화)
|
||||
2. Tesseract.js OCR (한글+영어)
|
||||
3. 정규식 기반 파싱 및 오타 보정
|
||||
4. 폼 자동입력
|
||||
```
|
||||
|
||||
### AI API 모드 (NEW!) ⭐
|
||||
|
||||
**특징:**
|
||||
- ✅ 매우 높은 정확도 (Vision AI)
|
||||
- ✅ 빠른 처리 속도
|
||||
- ✅ 문맥 이해 및 자동 보정
|
||||
- ✅ 복잡한 레이아웃도 정확히 인식
|
||||
- ⚠️ API 비용 발생
|
||||
- ⚠️ 인터넷 연결 필요
|
||||
|
||||
**처리 과정:**
|
||||
```
|
||||
1. 이미지 전처리 (동일)
|
||||
2. Tesseract.js OCR (텍스트 추출용)
|
||||
3. Claude AI API 호출
|
||||
- 이미지 분석 (Vision)
|
||||
- OCR 텍스트 분석
|
||||
- 구조화된 JSON 반환
|
||||
4. 폼 자동입력
|
||||
```
|
||||
|
||||
**사용 모델:**
|
||||
- **Claude 3 Haiku** (`claude-3-haiku-20240307`)
|
||||
- 가장 빠르고 비용 효율적인 Claude 모델
|
||||
- Vision 기능 지원 (이미지 직접 분석)
|
||||
- OCR 데이터 추출에 최적화
|
||||
|
||||
### 선택 가이드
|
||||
|
||||
**JavaScript OCR 추천:**
|
||||
- 💰 비용이 중요한 경우
|
||||
- 🔒 데이터 보안이 최우선인 경우
|
||||
- 📶 인터넷 연결이 불안정한 경우
|
||||
- 📄 간단하고 깨끗한 이미지
|
||||
|
||||
**AI API 추천:**
|
||||
- 🎯 정확도가 최우선인 경우
|
||||
- ⚡ 빠른 처리 속도가 필요한 경우
|
||||
- 📸 저품질 이미지나 복잡한 레이아웃
|
||||
- 🏢 대량 처리가 필요한 경우
|
||||
|
||||
## 🚀 사용 방법
|
||||
|
||||
### 1. 새 사업자등록증 등록
|
||||
|
||||
1. `ocr/index.php` 페이지 접속
|
||||
2. **OCR 모드 선택** (토글 버튼) ⭐NEW
|
||||
- 왼쪽 (JS 사용): 브라우저 OCR
|
||||
- 오른쪽 (AI API): Claude AI 분석
|
||||
3. "파일 선택" 버튼 클릭하여 사업자등록증 이미지 또는 PDF 선택
|
||||
4. 자동으로 OCR 처리가 시작됨
|
||||
- JS 모드: 15-30초 소요
|
||||
- AI 모드: 5-10초 소요
|
||||
5. **인식된 정보가 폼에 자동으로 입력됨** (노란색 배경으로 표시)
|
||||
- 사업자등록번호
|
||||
- 상호명
|
||||
- 대표자명
|
||||
- 개업일자
|
||||
- 본점 소재지
|
||||
- 업태/종목
|
||||
- 발급일자
|
||||
6. 자동 입력된 내용 확인 및 필요시 수동 수정
|
||||
7. "저장" 버튼 클릭
|
||||
|
||||
### 2. 목록 조회
|
||||
|
||||
1. `ocr/list.php` 페이지 접속
|
||||
2. 등록된 사업자등록증 목록 확인
|
||||
3. 검색창에서 사업자번호, 상호명, 대표자명으로 검색 가능
|
||||
4. "보기" 버튼 클릭하여 상세정보 확인
|
||||
|
||||
### 3. 상세정보 보기
|
||||
|
||||
- 목록 페이지에서 "보기" 클릭
|
||||
- 모달 팝업으로 전체 정보 표시
|
||||
- OCR 원문 텍스트도 확인 가능
|
||||
|
||||
## 🔧 기술 스택
|
||||
|
||||
### 프론트엔드
|
||||
- **PDF.js 4.6.82**: PDF를 이미지로 변환
|
||||
- **Tesseract.js 5.x**: 브라우저 기반 OCR (한글+영어)
|
||||
- **Vanilla JavaScript**: DOM 조작 및 API 통신
|
||||
- **Fetch API**: Claude API 비동기 호출 ⭐NEW
|
||||
|
||||
### 백엔드
|
||||
- **PHP 7.3+**: 서버 로직
|
||||
- **PDO**: 데이터베이스 연결
|
||||
- **MySQL/MariaDB**: 데이터 저장
|
||||
- **cURL**: Claude API HTTP 통신 ⭐NEW
|
||||
|
||||
### AI/ML
|
||||
- **Claude 3 Haiku API**: Vision 기반 OCR 및 데이터 추출 ⭐NEW
|
||||
- **Anthropic API v1**: Claude API 엔드포인트
|
||||
- **Vision API**: 이미지 직접 분석
|
||||
|
||||
## 📝 주요 기능 설명
|
||||
|
||||
### OCR 자동 인식 및 자동 입력
|
||||
|
||||
#### 🎯 핵심 기능
|
||||
- 사업자등록번호, 상호명, 대표자명 등 8개 필드 자동 추출
|
||||
- **인식률: 70-90%** (이미지 품질에 따라 변동)
|
||||
|
||||
#### 🔧 오인식 보정 (ENHANCED!)
|
||||
OCR 오류를 자동으로 보정하여 인식률 향상:
|
||||
|
||||
**특수문자 제거 (최우선 처리)** ✨NEW:
|
||||
- 한글 사이의 방해 특수문자 자동 제거: `_`, `.`, `-`, `~`, `` ` ``, `'`, `"`
|
||||
- 예시: `대 _ 표 . 자` → `대 표 자` → `대표자`
|
||||
|
||||
**한글 오인식 보정:**
|
||||
- `수식회사`, `수 식 회 사` → `주식회사` (공백 포함) ✨ENHANCED
|
||||
- `수식호사`, `주식호사` → `주식회사` (호사 오타)
|
||||
- `법인면` → `법인명` (면 → 명)
|
||||
- `대=자` → `대표자` (= → 표)
|
||||
- `대 표 차` → `대표자` (차 → 자)
|
||||
- `얼태` → `업태`
|
||||
- `총 록` → `종목` (총록 → 종목)
|
||||
- `총 목` → `종목` (총목 → 종목)
|
||||
- `개 업 면 월 일` → `개업명월일` (공백 무시 + 오타 보정)
|
||||
- `엘 리 베 이 터` → `엘리베이터`
|
||||
- `하 장 품` → `부장품`
|
||||
- `의 장 품` → `의장품` (보이는 물건)
|
||||
|
||||
**날짜 관련 오인식 보정:**
|
||||
- `뭘` → `월` (06 뭘 → 06 월) ✨NEW
|
||||
- `뭠` → `월`
|
||||
- `울` → `월`
|
||||
- `융` → `일`
|
||||
- `임` → `일`
|
||||
- `연` → `년`
|
||||
- `념` → `년`
|
||||
|
||||
**숫자 오인식 보정:**
|
||||
- `O`, `o` → `0`
|
||||
- `I`, `l` → `1`
|
||||
- `Z`, `z` → `2`
|
||||
- `S` → `5`
|
||||
- `b` → `6`
|
||||
|
||||
#### 🎪 유연한 키워드 매칭 (ENHANCED!)
|
||||
오타나 공백이 있어도 키워드 근처에서 정보 추출:
|
||||
```
|
||||
예시 1: "법 인 면 ( 단 체 면 ) : 수 식 회 사 미 래 기 업" ✨ENHANCED
|
||||
→ 특수문자 제거: "법인면 (단체면) : 수 식 회 사 미래기업"
|
||||
→ 오타 보정: "법인명 (단체명) : 주식회사 미래기업"
|
||||
→ 상호명 추출 성공: "주식회사미래기업"
|
||||
|
||||
예시 2: "대 _ 표 . 자 : 소 현 철" ✨NEW
|
||||
→ 특수문자 제거: "대 표 자 : 소 현 철"
|
||||
→ 오타 보정: "대표자 : 소 현 철"
|
||||
→ "대표자" 키워드 감지 → 한글 이름 패턴 (2-4자) → 공백 제거
|
||||
→ 추출 성공: "소현철"
|
||||
|
||||
예시 3: "업태 제조업 [ 총 록 | 엘 리 베 이 터 의 장 품"
|
||||
→ 오타 보정: "업태 제조업 [ 종목 | 엘리베이터 의장품"
|
||||
→ "업태" 키워드 감지 → 업태 추출: "제조업"
|
||||
→ "종목" 키워드 감지 → 종목 추출: "엘리베이터의장품"
|
||||
|
||||
예시 4: "개 업 면 월 일 : 2015 년 06 뭘 02 일"
|
||||
→ 오타 보정: "개업명월일 : 2015 년 06 월 02 일"
|
||||
→ "개업" 키워드 감지 → 날짜 추출 성공 → 2015-06-02
|
||||
```
|
||||
|
||||
#### 📊 다중 패턴 매칭
|
||||
- **1단계**: 오인식 보정된 텍스트에서 매칭 시도
|
||||
- **2단계**: 원본 텍스트에서 매칭 시도
|
||||
- **3단계**: 키워드 근처에서 유연하게 검색
|
||||
- **4단계**: 한국 이름 패턴 (2-4자) 길이 검증 ✨NEW
|
||||
|
||||
#### 🧹 공백 및 텍스트 정리
|
||||
- **공백 제거**: "미 래 기 업" → "미래기업"
|
||||
- **한글 연결**: 글자 사이 공백 자동 제거
|
||||
- **특수문자 제거**: 불필요한 기호 정리
|
||||
- **날짜 정규화**: "2015 년 06 월 02 일" → "2015-06-02"
|
||||
- **한국 이름 패턴 검증** ✨NEW:
|
||||
- 2-4자 한글 이름 패턴 인식
|
||||
- 공백이 포함된 이름도 자동 정리 ("소 현 철" → "소현철")
|
||||
- 4자 초과 시 앞 3자만 추출 (일반적인 한국 이름)
|
||||
|
||||
#### 🎨 자동 폼 입력
|
||||
- OCR 완료 후 자동으로 입력 필드에 값 채움
|
||||
- **시각적 표시**: 자동 입력된 필드는 노란색 배경으로 표시
|
||||
- **성공률 표시**: 상태 메시지에 추출 성공 개수 표시
|
||||
|
||||
#### 🔍 상세 로깅
|
||||
브라우저 콘솔(F12)에서 확인 가능:
|
||||
1. **OCR 원문**: 실제 인식된 텍스트
|
||||
2. **오인식 보정 후**: 자동 보정된 텍스트
|
||||
3. **패턴 매칭 과정**: 각 필드별 추출 시도 로그
|
||||
4. **최종 추출 결과**: 각 필드 성공/실패 상태
|
||||
5. **추출 성공률**: X/8 필드 (XX%)
|
||||
|
||||
### 사업자번호 검증
|
||||
- 10자리 숫자 검증
|
||||
- 체크섬 알고리즘으로 유효성 확인
|
||||
- 검증 실패시 경고 표시
|
||||
|
||||
### 지원 파일 형식
|
||||
- **이미지**: JPG, PNG, GIF, BMP 등
|
||||
- **PDF**: 첫 페이지 자동 추출
|
||||
|
||||
## ⚙️ 설정
|
||||
|
||||
### 권한 설정
|
||||
- 레벨 5 이하 사용자만 접근 가능
|
||||
- `session.php`에서 권한 체크
|
||||
|
||||
### AI API 설정 (NEW!) ⭐
|
||||
|
||||
#### 1. API 키 발급
|
||||
1. [Claude Console](https://console.anthropic.com/) 접속
|
||||
2. 회원가입 및 로그인
|
||||
3. **Settings → API Keys** 메뉴
|
||||
4. **Create Key** 버튼 클릭
|
||||
5. API 키 복사 (예: `sk-ant-api03-...`)
|
||||
|
||||
#### 2. API 키 저장
|
||||
```bash
|
||||
# API 키 파일 생성
|
||||
/chandj/www/apikey/claude_api.txt
|
||||
|
||||
# 파일 내용 (API 키만 입력, 개행 없이)
|
||||
sk-ant-api03-jevRUT9wPnqGQs5egKfSf0DTYdnlTL...
|
||||
```
|
||||
|
||||
**중요:**
|
||||
- 파일 경로: `{DOCUMENT_ROOT}/apikey/claude_api.txt`
|
||||
- 파일 권한: `chmod 600 claude_api.txt` (읽기 전용)
|
||||
- 보안: `.gitignore`에 추가하여 버전 관리에서 제외
|
||||
|
||||
#### 3. API 키 테스트
|
||||
```bash
|
||||
# 브라우저에서 접속
|
||||
http://your-domain/ocr/test_api.php
|
||||
```
|
||||
|
||||
정상 응답 예시:
|
||||
```json
|
||||
{
|
||||
"file_exists": true,
|
||||
"file_path": "/chandj/www/apikey/claude_api.txt",
|
||||
"key_length": 108,
|
||||
"key_prefix": "sk-ant-api03-jev...",
|
||||
"key_valid_format": true
|
||||
}
|
||||
```
|
||||
|
||||
#### 4. 사용 가능한 모델 확인
|
||||
```bash
|
||||
# 브라우저에서 접속
|
||||
http://your-domain/ocr/test_models.php
|
||||
```
|
||||
|
||||
정상 응답 예시:
|
||||
```json
|
||||
{
|
||||
"claude-3-haiku-20240307": {
|
||||
"status": 200,
|
||||
"success": true,
|
||||
"response": "OK"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
#### 5. API 비용
|
||||
Claude API는 **사용량 기반 과금**입니다:
|
||||
|
||||
| 모델 | Input | Output | 이미지 |
|
||||
|------|-------|--------|--------|
|
||||
| **Claude 3 Haiku** | $0.25 / 1M tokens | $1.25 / 1M tokens | $0.40 / 1000 images |
|
||||
|
||||
**예상 비용 (사업자등록증 1건):**
|
||||
- 이미지: ~$0.0004 (1장)
|
||||
- 텍스트: ~$0.0002 (입력+출력)
|
||||
- **총 비용: ~$0.0006 (약 0.8원)**
|
||||
|
||||
**무료 크레딧:**
|
||||
- 신규 가입 시 $5 무료 크레딧 제공
|
||||
- 약 8,000건 처리 가능
|
||||
|
||||
#### 6. 보안 설정
|
||||
```php
|
||||
// claude_api.php 파일에서 자동 처리
|
||||
// - API 키는 서버에서만 읽음
|
||||
// - 클라이언트에 노출되지 않음
|
||||
// - 세션 권한 체크 (레벨 5 이하)
|
||||
```
|
||||
|
||||
### OCR 언어 설정
|
||||
- 한글(`kor`) + 영어(`eng`) 동시 인식
|
||||
- 학습 데이터: `https://tessdata.projectnaptha.com/4.0.0`
|
||||
|
||||
### PDF 렌더링 설정
|
||||
- Scale: 3.0 (고해상도 인식)
|
||||
- 첫 페이지만 처리 (속도 최적화)
|
||||
|
||||
## 🔍 한글 인식률 향상 방법
|
||||
|
||||
### 자동 적용되는 기능 (이미 구현됨)
|
||||
|
||||
본 시스템은 다음 기능들이 **자동으로 적용**됩니다:
|
||||
|
||||
#### 1. **이미지 전처리 파이프라인**
|
||||
```
|
||||
원본 이미지
|
||||
↓
|
||||
그레이스케일 변환 (컬러 제거)
|
||||
↓
|
||||
대비 증가 (Contrast Enhancement, factor: 1.5)
|
||||
↓
|
||||
이진화 (Binarization, threshold: 128)
|
||||
↓
|
||||
OCR 실행
|
||||
```
|
||||
|
||||
#### 2. **고해상도 렌더링**
|
||||
- **PDF**: 3.0x 스케일로 렌더링 (기본 2.0에서 증가)
|
||||
- **이미지**: 2.0x 리샘플링으로 확대
|
||||
|
||||
#### 3. **Tesseract 최적화 설정**
|
||||
- `tessedit_pageseg_mode: 1` - 자동 페이지 분할
|
||||
- `preserve_interword_spaces: 1` - 단어 간 공백 유지
|
||||
- 한글(kor) + 영어(eng) 동시 인식
|
||||
|
||||
### 추가 향상 팁 (사용자 제공)
|
||||
|
||||
1. **고품질 이미지 사용**: 300 DPI 이상 권장
|
||||
2. **조명 개선**: 사업자등록증을 깨끗하고 밝은 곳에서 촬영
|
||||
3. **수평 정렬**: 카메라를 정면에서 수평으로 촬영
|
||||
4. **그림자 제거**: 그림자나 반사가 없도록 촬영
|
||||
5. **배경 단순화**: 깨끗한 흰색/단색 배경 사용
|
||||
|
||||
### 인식률 비교
|
||||
|
||||
| 구분 | 일반 OCR | 전처리 OCR | 전처리 + 보정 |
|
||||
|------|----------|-----------|--------------|
|
||||
| **선명도** | 보통 | 높음 | 높음 |
|
||||
| **대비** | 원본 | 1.5배 | 1.5배 |
|
||||
| **해상도** | 1x | 2-3x | 2-3x |
|
||||
| **오타 보정** | ❌ | ❌ | ✅ |
|
||||
| **유연 매칭** | ❌ | ❌ | ✅ |
|
||||
| **한글 인식률** | 50-70% | 70-85% | **80-90%** |
|
||||
| **처리 시간** | 10초 | 15초 | 20초 |
|
||||
|
||||
### 전처리 효과 예시
|
||||
|
||||
**원본**: 흐릿한 배경, 낮은 대비
|
||||
```
|
||||
미 래 기 업 → 인식 실패 또는 "미 래 기 업"
|
||||
```
|
||||
|
||||
**전처리 후**: 명확한 흑백, 높은 대비
|
||||
```
|
||||
미래기업 → 정확히 인식 "미래기업"
|
||||
```
|
||||
|
||||
### 오인식 보정 효과 예시 (ENHANCED!)
|
||||
|
||||
#### 예시 1: 특수문자 + 오타 보정 (상호명) ✨ENHANCED
|
||||
```
|
||||
OCR 인식: "법 인 면 ( 단 체 면 ) : 수 식 회 사 미 래 기 업"
|
||||
→ 1단계 (특수문자 제거): "법인면 (단체면) : 수 식 회 사 미래기업"
|
||||
→ 2단계 (오타 보정 - 공백 포함): "법인명 (단체명) : 주식회사 미래기업"
|
||||
→ 3단계 (공백 제거): "법인명(단체명):주식회사미래기업"
|
||||
→ 최종 추출: "주식회사미래기업" ✅
|
||||
|
||||
주요 개선점:
|
||||
- '수 식 회 사' (공백 포함) → '주식회사'로 자동 보정
|
||||
- '수식호사', '주식호사' 같은 오타도 자동 보정
|
||||
```
|
||||
|
||||
#### 예시 2: 특수문자 + 오타 보정 (대표자명) ✨NEW
|
||||
```
|
||||
OCR 인식: "대 _ 표 . 자 : 소 현 철"
|
||||
→ 1단계 (특수문자 제거): "대 표 자 : 소 현 철"
|
||||
→ 2단계 (오타 보정): "대표자 : 소 현 철"
|
||||
→ 3단계 (키워드 감지): "대표자" 근처에서 한글 이름 패턴 검색
|
||||
→ 4단계 (이름 추출): "소 현 철" (공백 포함)
|
||||
→ 5단계 (공백 제거): "소현철"
|
||||
→ 6단계 (길이 검증): 3자 (2-4자 범위) ✓
|
||||
→ 최종 추출: "소현철" ✅
|
||||
```
|
||||
|
||||
#### 예시 3: 업태/종목 오타 + 공백 처리
|
||||
```
|
||||
OCR 인식: "업태 제조업 [ 총 록 | 엘 리 베 이 터 의 장 품"
|
||||
→ 보정: "업태 제조업 [ 종목 | 엘리베이터 의장품"
|
||||
→ 업태 키워드 감지: "업태" 근처에서 값 검색
|
||||
→ 업태 추출: "제조업" (기호 제거)
|
||||
→ 종목 키워드 감지: "종목" 근처에서 값 검색
|
||||
→ 종목 추출: "엘리베이터의장품" (공백 제거 + 기호 제거)
|
||||
→ 최종 결과: 업태="제조업", 종목="엘리베이터의장품" ✅
|
||||
```
|
||||
|
||||
#### 예시 4: 날짜 오타 보정
|
||||
```
|
||||
OCR 인식: "개 업 면 월 일 : 2015 년 06 뭘 02 일"
|
||||
→ 보정: "개업명월일 : 2015 년 06 월 02 일"
|
||||
→ 키워드 감지: "개업" 근처에서 날짜 패턴 검색
|
||||
→ 유효성 검증: year=2015, month=6, day=2 ✓
|
||||
→ 추출: "2015-06-02" ✅
|
||||
```
|
||||
|
||||
#### 예시 5: 복합 오류 처리
|
||||
```
|
||||
OCR 인식: "사업자드로즈빼세시 등록번호: 722-88-00035"
|
||||
→ 숫자 패턴 우선 검색
|
||||
→ 추출: "722-88-00035" ✅
|
||||
```
|
||||
|
||||
### 🚀 더 높은 인식률이 필요한 경우
|
||||
|
||||
현재 시스템(브라우저 OCR)으로도 85-95%의 인식률을 달성하지만, 더 높은 정확도가 필요한 경우:
|
||||
|
||||
#### 1. **서버 OCR 사용** (권장)
|
||||
브라우저 OCR 대신 서버 기반 OCR 솔루션 사용:
|
||||
|
||||
**장점**:
|
||||
- 더 빠른 처리 속도 (GPU 활용)
|
||||
- 높은 정확도 (95-99%)
|
||||
- 고급 전처리 기능
|
||||
|
||||
**추천 솔루션**:
|
||||
- **Tesseract 5.x** (서버 설치): 무료, 오픈소스
|
||||
- **Google Cloud Vision API**: 유료, 매우 정확
|
||||
- **Naver Clova OCR**: 한글 특화, 유료
|
||||
- **Kakao Vision API**: 한글 지원, 유료
|
||||
|
||||
#### 2. **하이브리드 방식**
|
||||
```
|
||||
1차: 브라우저 OCR (빠른 프리뷰)
|
||||
2차: 서버 OCR (정확한 최종 결과)
|
||||
```
|
||||
|
||||
#### 3. **전처리 파라미터 조정**
|
||||
`index.php`에서 전처리 설정 조정:
|
||||
|
||||
```javascript
|
||||
// 대비 계수 조정 (현재 1.5)
|
||||
const factor = 2.0; // 더 강한 대비 (단, 너무 높으면 노이즈 증가)
|
||||
|
||||
// 이진화 임계값 조정 (현재 128)
|
||||
const threshold = 140; // 밝은 이미지: 높게, 어두운 이미지: 낮게
|
||||
|
||||
// PDF 스케일 조정 (현재 3.0)
|
||||
const viewport = page.getViewport({ scale: 4.0 }); // 더 고해상도
|
||||
```
|
||||
|
||||
#### 4. **언어팩 최적화**
|
||||
```javascript
|
||||
// 한글만 인식 (영어 제거하면 한글 정확도 소폭 상승)
|
||||
'kor' // 대신 'kor+eng'
|
||||
|
||||
// 또는 특화된 학습 데이터 사용
|
||||
langPath: 'https://github.com/tesseract-ocr/tessdata_best/raw/main'
|
||||
```
|
||||
|
||||
### 📊 인식률 향상 체크리스트
|
||||
|
||||
- [x] 이미지 전처리 (그레이스케일, 대비, 이진화)
|
||||
- [x] 고해상도 렌더링 (PDF 3x, 이미지 2x)
|
||||
- [x] Tesseract 최적화 설정
|
||||
- [x] 공백 제거 및 텍스트 정규화
|
||||
- [x] 다중 패턴 매칭
|
||||
- [ ] 고품질 원본 이미지 (사용자 제공)
|
||||
- [ ] 서버 OCR 도입 (선택사항)
|
||||
|
||||
## 🐛 자동 입력이 안 되는 경우 디버깅
|
||||
|
||||
### 1. 브라우저 콘솔 확인 (F12)
|
||||
OCR 처리 중 콘솔에 다음 정보가 표시됩니다:
|
||||
```
|
||||
=== OCR 원문 ===
|
||||
등 록 번 호 : 722-88-00035
|
||||
법 인 명 ( 단 체 명) : 주식회사 미 래 기 업
|
||||
대 = 자 : 소 현 철
|
||||
개 업 연 월 일 : 2015 년 06 월 02 일
|
||||
...
|
||||
===============
|
||||
|
||||
=== 추출 결과 ===
|
||||
{
|
||||
biz_no: "722-88-00035",
|
||||
company_name: "주식회사미래기업",
|
||||
representative: "소현철",
|
||||
open_date: "2015-06-02",
|
||||
...
|
||||
}
|
||||
================
|
||||
|
||||
=== 필드 자동입력 시작 ===
|
||||
biz_no: "722-88-00035" → 입력
|
||||
company_name: "주식회사미래기업" → 입력
|
||||
representative: "소현철" → 입력
|
||||
open_date: "2015-06-02" → 입력
|
||||
...
|
||||
총 5/8 필드 입력 완료
|
||||
=========================
|
||||
```
|
||||
|
||||
위 예시처럼 **공백이 많이 섞인 OCR 원문도 자동으로 처리**됩니다.
|
||||
|
||||
### 2. 인식률 확인
|
||||
- **OCR 원문**에서 실제로 텍스트가 제대로 인식되었는지 확인
|
||||
- 인식이 잘못된 경우: 이미지 품질 개선 필요
|
||||
- 인식은 되었으나 추출 실패: 정규식 패턴 개선 필요
|
||||
|
||||
### 3. 수동 입력 후 저장
|
||||
- 자동 추출이 실패해도 수동으로 입력 가능
|
||||
- OCR 원문을 참고하여 정확한 정보 입력
|
||||
|
||||
### 4. 패턴 개선 (개발자용)
|
||||
`parseBizCert` 함수의 정규식 패턴을 실제 사업자등록증 양식에 맞게 조정:
|
||||
```javascript
|
||||
// 예: 상호명 추출 패턴 추가
|
||||
let companyName = pickMultiple([
|
||||
/상\s*호\s*명?[:\s]*([^\n\r]+?)(?=\s*(?:대표자|전화|업태|종목|주소|소재지|$))/ui,
|
||||
/상\s*호[:\s]+([^\n\r]+?)(?=\s*(?:대표자|전화|$))/ui,
|
||||
// 새로운 패턴 추가
|
||||
/회사명[:\s]+([^\n\r]+?)/ui
|
||||
]);
|
||||
```
|
||||
|
||||
## 🛠️ 문제 해결
|
||||
|
||||
### OCR이 실패하는 경우
|
||||
- 이미지 품질 확인 (흐릿하거나 어두운 경우)
|
||||
- 브라우저 콘솔에서 에러 메시지 확인
|
||||
- 파일 크기 확인 (너무 큰 파일은 로딩 지연)
|
||||
|
||||
### 한글이 인식되지 않는 경우
|
||||
- 언어팩 다운로드 대기 (최초 실행시 시간 소요)
|
||||
- 네트워크 연결 확인
|
||||
- CDN 접근 가능 여부 확인
|
||||
|
||||
### 저장이 안 되는 경우
|
||||
- 필수 항목 입력 확인 (사업자번호, 상호명, 대표자명)
|
||||
- 데이터베이스 연결 확인
|
||||
- 브라우저 콘솔에서 API 응답 확인
|
||||
|
||||
### 콘솔 경고 메시지 (정상 동작)
|
||||
다음과 같은 경고 메시지들이 나타날 수 있으나, 이는 **정상적인 동작**입니다:
|
||||
```
|
||||
Warning: Parameter not found: segsearch_max_char_wh_ratio
|
||||
Warning: Parameter not found: allow_blob_division
|
||||
Warning: Parameter not found: assume_fixed_pitch_char_segment
|
||||
```
|
||||
- Tesseract.js 라이브러리 내부에서 발생하는 경고
|
||||
- OCR 기능 동작에는 영향 없음
|
||||
- 무시해도 안전함
|
||||
|
||||
## 📊 성능 최적화
|
||||
|
||||
### 브라우저 OCR의 특징
|
||||
- **장점**: 서버 부하 없음, 별도 설치 불필요
|
||||
- **단점**: 처리 속도가 서버 OCR보다 느림
|
||||
|
||||
### 최적화 전략
|
||||
- 첫 페이지만 처리 (다중 페이지는 순차 처리)
|
||||
- 언어팩 CDN 캐싱 활용
|
||||
- 이미지 크기 적절히 조정
|
||||
|
||||
## 🔐 보안 고려사항
|
||||
|
||||
1. **권한 체크**: 모든 페이지에서 사용자 레벨 확인
|
||||
2. **SQL Injection 방지**: PDO prepared statements 사용
|
||||
3. **XSS 방지**: `htmlspecialchars()` 사용
|
||||
4. **민감정보 처리**: 필요시 주민번호 마스킹 추가
|
||||
|
||||
## 🚀 향후 개선 사항
|
||||
|
||||
- [x] AI API 통합 (Claude Vision) ⭐COMPLETED
|
||||
- [x] 듀얼 OCR 모드 토글 ⭐COMPLETED
|
||||
- [ ] 다중 페이지 PDF 지원
|
||||
- [ ] 이미지 회전 및 보정 기능
|
||||
- [ ] 수정/삭제 기능 추가
|
||||
- [ ] 엑셀 내보내기 기능
|
||||
- [ ] 파일 첨부 기능 (원본 이미지 보관)
|
||||
- [ ] 중복 사업자번호 체크
|
||||
- [ ] 통계 및 대시보드
|
||||
- [ ] AI 모델 자동 fallback (Haiku → Sonnet → Opus)
|
||||
- [ ] OCR 결과 신뢰도 점수 표시
|
||||
|
||||
## 📞 문의
|
||||
|
||||
- 버그 리포트 및 기능 제안은 개발팀에 문의
|
||||
- 상세 개발 문서: `dev.md` 참조
|
||||
|
||||
---
|
||||
|
||||
**개발 완료일**: 2025-10-29
|
||||
**최신 업데이트**: AI API 통합 (Claude 3 Haiku)
|
||||
**호환성**: PHP 7.3+, MySQL 5.7+
|
||||
**라이선스**: 내부 사용
|
||||
|
||||
168
ocr/claude_api.php
Normal file
168
ocr/claude_api.php
Normal file
@@ -0,0 +1,168 @@
|
||||
<?php
|
||||
require_once($_SERVER['DOCUMENT_ROOT'] . "/session.php");
|
||||
|
||||
// 권한 체크
|
||||
if ($level > 5) {
|
||||
echo json_encode(['ok' => false, 'error' => '접근 권한이 없습니다.']);
|
||||
exit;
|
||||
}
|
||||
|
||||
// POST 데이터 받기
|
||||
$input = file_get_contents('php://input');
|
||||
$data = json_decode($input, true);
|
||||
|
||||
if (!isset($data['image'])) {
|
||||
echo json_encode(['ok' => false, 'error' => '이미지 데이터가 없습니다.']);
|
||||
exit;
|
||||
}
|
||||
|
||||
$imageBase64 = $data['image'];
|
||||
$rawText = isset($data['raw_text']) ? $data['raw_text'] : null; // OCR 텍스트는 선택사항
|
||||
|
||||
// API 키 읽기
|
||||
$apiKeyFile = $_SERVER['DOCUMENT_ROOT'] . '/apikey/claude_api.txt';
|
||||
if (!file_exists($apiKeyFile)) {
|
||||
echo json_encode(['ok' => false, 'error' => 'API 키 파일이 존재하지 않습니다.']);
|
||||
exit;
|
||||
}
|
||||
|
||||
$apiKey = trim(file_get_contents($apiKeyFile));
|
||||
|
||||
// Claude API 요청
|
||||
$promptText = "제공된 사업자등록증 이미지를 직접 분석하여 아래 필드를 정확하게 추출해주세요.\n\n";
|
||||
|
||||
if ($rawText) {
|
||||
$promptText .= "참고: OCR 텍스트가 제공되었지만 부정확할 수 있으니, 이미지를 직접 읽어서 정확한 정보를 추출해주세요.\n";
|
||||
$promptText .= "OCR 텍스트(참고용): {$rawText}\n\n";
|
||||
}
|
||||
|
||||
$promptText .= <<<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;
|
||||
|
||||
// Claude API 호출
|
||||
$apiUrl = 'https://api.anthropic.com/v1/messages';
|
||||
|
||||
// 이미지 데이터 준비 (base64에서 data:image/... 부분 제거)
|
||||
$imageData = $imageBase64;
|
||||
if (preg_match('/^data:image\/(\w+);base64,(.+)$/', $imageBase64, $matches)) {
|
||||
$imageType = $matches[1]; // png, jpeg 등
|
||||
$imageData = $matches[2]; // 순수 base64
|
||||
$mediaType = "image/" . $imageType;
|
||||
} else {
|
||||
// data: 부분이 없으면 PNG로 가정
|
||||
$mediaType = "image/png";
|
||||
}
|
||||
|
||||
$requestBody = [
|
||||
'model' => 'claude-3-haiku-20240307', // haiku 모델 사용 (이미지 직접 분석 가능)
|
||||
'max_tokens' => 4096,
|
||||
'messages' => [
|
||||
[
|
||||
'role' => 'user',
|
||||
'content' => [
|
||||
[
|
||||
'type' => 'image',
|
||||
'source' => [
|
||||
'type' => 'base64',
|
||||
'media_type' => $mediaType,
|
||||
'data' => $imageData
|
||||
]
|
||||
],
|
||||
[
|
||||
'type' => 'text',
|
||||
'text' => $promptText
|
||||
]
|
||||
]
|
||||
]
|
||||
]
|
||||
];
|
||||
|
||||
$ch = curl_init($apiUrl);
|
||||
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
|
||||
curl_setopt($ch, CURLOPT_POST, true);
|
||||
curl_setopt($ch, CURLOPT_HTTPHEADER, [
|
||||
'Content-Type: application/json',
|
||||
'x-api-key: ' . $apiKey,
|
||||
'anthropic-version: 2023-06-01'
|
||||
]);
|
||||
curl_setopt($ch, CURLOPT_POSTFIELDS, json_encode($requestBody));
|
||||
|
||||
$response = curl_exec($ch);
|
||||
$httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE);
|
||||
curl_close($ch);
|
||||
|
||||
if ($httpCode !== 200) {
|
||||
error_log("Claude API Error: HTTP {$httpCode} - {$response}");
|
||||
echo json_encode([
|
||||
'ok' => false,
|
||||
'error' => 'Claude API 호출 실패 (HTTP ' . $httpCode . ')',
|
||||
'details' => $response
|
||||
]);
|
||||
exit;
|
||||
}
|
||||
|
||||
$apiResponse = json_decode($response, true);
|
||||
|
||||
if (!isset($apiResponse['content'][0]['text'])) {
|
||||
echo json_encode(['ok' => false, 'error' => 'Claude API 응답 형식 오류']);
|
||||
exit;
|
||||
}
|
||||
|
||||
// Claude가 반환한 JSON 파싱
|
||||
$claudeText = $apiResponse['content'][0]['text'];
|
||||
|
||||
// JSON 부분만 추출 (코드블록이나 설명이 포함되어 있을 수 있음)
|
||||
if (preg_match('/\{[^{}]*(?:\{[^{}]*\}[^{}]*)*\}/s', $claudeText, $matches)) {
|
||||
$jsonText = $matches[0];
|
||||
} else {
|
||||
$jsonText = $claudeText;
|
||||
}
|
||||
|
||||
$extractedData = json_decode($jsonText, true);
|
||||
|
||||
if (!$extractedData) {
|
||||
echo json_encode([
|
||||
'ok' => false,
|
||||
'error' => 'Claude 응답 JSON 파싱 실패',
|
||||
'raw_response' => $claudeText
|
||||
]);
|
||||
exit;
|
||||
}
|
||||
|
||||
// 성공 응답
|
||||
echo json_encode([
|
||||
'ok' => true,
|
||||
'data' => $extractedData,
|
||||
'raw_response' => $claudeText
|
||||
]);
|
||||
47
ocr/delete.php
Normal file
47
ocr/delete.php
Normal file
@@ -0,0 +1,47 @@
|
||||
<?php
|
||||
require_once($_SERVER['DOCUMENT_ROOT'] . "/session.php");
|
||||
require_once(__DIR__ . '/../lib/mydb.php');
|
||||
|
||||
// 권한 체크 (레벨 5 이하만 접근)
|
||||
if ($level > 5) {
|
||||
header('Content-Type: application/json; charset=utf-8');
|
||||
echo json_encode(['ok' => false, 'error' => '접근 권한이 없습니다.'], JSON_UNESCAPED_UNICODE);
|
||||
exit;
|
||||
}
|
||||
|
||||
header('Content-Type: application/json; charset=utf-8');
|
||||
|
||||
try {
|
||||
$input = json_decode(file_get_contents('php://input'), true);
|
||||
$id = isset($input['id']) ? intval($input['id']) : 0;
|
||||
|
||||
if ($id <= 0) {
|
||||
echo json_encode(['ok' => false, 'error' => '잘못된 ID입니다.'], JSON_UNESCAPED_UNICODE);
|
||||
exit;
|
||||
}
|
||||
|
||||
$pdo = db_connect();
|
||||
|
||||
// 데이터 존재 확인
|
||||
$check_sql = "SELECT id FROM biz_cert WHERE id = :id";
|
||||
$check_stmt = $pdo->prepare($check_sql);
|
||||
$check_stmt->bindValue(':id', $id, PDO::PARAM_INT);
|
||||
$check_stmt->execute();
|
||||
|
||||
if (!$check_stmt->fetch()) {
|
||||
echo json_encode(['ok' => false, 'error' => '해당 데이터를 찾을 수 없습니다.'], JSON_UNESCAPED_UNICODE);
|
||||
exit;
|
||||
}
|
||||
|
||||
// 삭제 실행
|
||||
$delete_sql = "DELETE FROM biz_cert WHERE id = :id";
|
||||
$delete_stmt = $pdo->prepare($delete_sql);
|
||||
$delete_stmt->bindValue(':id', $id, PDO::PARAM_INT);
|
||||
$delete_stmt->execute();
|
||||
|
||||
echo json_encode(['ok' => true], JSON_UNESCAPED_UNICODE);
|
||||
|
||||
} catch (Exception $e) {
|
||||
echo json_encode(['ok' => false, 'error' => '삭제 중 오류가 발생했습니다: ' . $e->getMessage()], JSON_UNESCAPED_UNICODE);
|
||||
}
|
||||
|
||||
857
ocr/dev.md
Normal file
857
ocr/dev.md
Normal file
@@ -0,0 +1,857 @@
|
||||
질문 : deepseek-OCR 이 공개 프로그램을 어떻게 사용할 수 있나?
|
||||
|
||||
답변 :
|
||||
딥시크(DeepSeek)의 공개 OCR 모델 **DeepSeek-OCR**를 로컬/서버에서 바로 돌릴 수 있어요. 핵심은 두 가지 실행 경로(vLLM 또는 Hugging Face Transformers) 중 하나를 고르는 것.
|
||||
|
||||
### 1) 이게 뭐고, 뭘 잘하나
|
||||
|
||||
* 문서/표/차트까지 포함한 **레이아웃 보존형 OCR → Markdown 변환**이 강점. 프롬프트로 “Convert the document to markdown” 같은 지시를 주면 구조화해서 뽑아줘요. ([GitHub][1])
|
||||
* 오픈소스(MIT) + 공개 가중치(Hugging Face). 최근 **vLLM 업스트림 공식 지원**도 들어갔습니다. ([GitHub][1])
|
||||
|
||||
---
|
||||
|
||||
## 빠른 설치 (CUDA 11.8, PyTorch 2.6 기준)
|
||||
|
||||
```bash
|
||||
# 0) 필수: NVIDIA 드라이버 + CUDA 11.8 런타임 환경
|
||||
# 1) 클론
|
||||
git clone https://github.com/deepseek-ai/DeepSeek-OCR.git
|
||||
cd DeepSeek-OCR
|
||||
|
||||
# 2) 가상환경 (conda 예시)
|
||||
conda create -n deepseek-ocr python=3.12.9 -y
|
||||
conda activate deepseek-ocr
|
||||
|
||||
# 3) 패키지 (vLLM 경로)
|
||||
pip install torch==2.6.0 torchvision==0.21.0 torchaudio==2.6.0 --index-url https://download.pytorch.org/whl/cu118
|
||||
# vLLM 0.8.5 wheel 설치 (리드미의 .whl 사용) + requirements
|
||||
pip install vllm-0.8.5+cu118-cp38-abi3-manylinux1_x86_64.whl
|
||||
pip install -r requirements.txt
|
||||
pip install flash-attn==2.7.3 --no-build-isolation
|
||||
```
|
||||
|
||||
> 위 버전 고정과 설치 순서는 프로젝트 리드미의 권장 스펙입니다. ([GitHub][1])
|
||||
|
||||
---
|
||||
|
||||
## 실행 ①: vLLM 경로 (성능/동시성 유리)
|
||||
|
||||
```bash
|
||||
cd DeepSeek-OCR-master/DeepSeek-OCR-vllm
|
||||
# config.py 에 INPUT_PATH / OUTPUT_PATH 등 경로 설정
|
||||
# 1) 단일/다중 이미지 OCR (스트리밍 출력)
|
||||
python run_dpsk_ocr_image.py
|
||||
# 2) PDF 대량 처리 (예: A100-40G 기준 ~2500 tokens/s 표기)
|
||||
python run_dpsk_ocr_pdf.py
|
||||
```
|
||||
|
||||
* vLLM 최신 야간 빌드로도 구동 가능 (업스트림 공식 지원). 예시 스니펫은 HF 모델카드 참고. ([GitHub][1])
|
||||
|
||||
---
|
||||
|
||||
## 실행 ②: Transformers 경로 (간단 통합/커스터마이즈 용이)
|
||||
|
||||
```python
|
||||
from transformers import AutoModel, AutoTokenizer
|
||||
import torch, os
|
||||
os.environ["CUDA_VISIBLE_DEVICES"] = '0'
|
||||
m = 'deepseek-ai/DeepSeek-OCR'
|
||||
tok = AutoTokenizer.from_pretrained(m, trust_remote_code=True)
|
||||
model = AutoModel.from_pretrained(
|
||||
m, _attn_implementation='flash_attention_2',
|
||||
trust_remote_code=True, use_safetensors=True
|
||||
).eval().cuda().to(torch.bfloat16)
|
||||
|
||||
prompt = "<image>\n<|grounding|>Convert the document to markdown. "
|
||||
image_file = "sample.jpg"
|
||||
output_dir = "./out"
|
||||
|
||||
res = model.infer(tok, prompt=prompt, image_file=image_file,
|
||||
output_path=output_dir, base_size=1024,
|
||||
image_size=640, crop_mode=True,
|
||||
save_results=True, test_compress=True)
|
||||
```
|
||||
|
||||
* 모델/토크나이저는 HF에서 받아옵니다. **Tiny/Small/Base/Large** 입력 해상도 프리셋도 제공. ([Hugging Face][2])
|
||||
|
||||
---
|
||||
|
||||
## 주요 프롬프트 예시
|
||||
|
||||
* 문서 → 마크다운:
|
||||
`<image>\n<|grounding|>Convert the document to markdown.`
|
||||
* 단순 문자만:
|
||||
`<image>\nFree OCR.`
|
||||
* 피겨/차트 파싱:
|
||||
`<image>\nParse the figure.`
|
||||
리드미에 여러 프롬프트가 예시로 포함되어 있어요. ([GitHub][1])
|
||||
|
||||
---
|
||||
|
||||
## PDF 배치 처리·API로 감싸기
|
||||
|
||||
* **공식 스크립트**: `run_dpsk_ocr_pdf.py` 로 폴더 단위 처리. ([GitHub][1])
|
||||
* **빠른 웹/API 템플릿**(비공식): FastAPI+React로 감싼 샘플들이 여럿 있어요.
|
||||
예: “PDF→Markdown 변환” REST API, 또는 데모 웹앱. ([GitHub][3])
|
||||
* **러스트 경량 서버**(CPU/Metal/초기 CUDA): OpenAI 호환 HTTP 서버/CLI 포함. 로컬 파이프라인 구성에 유용. ([GitHub][4])
|
||||
|
||||
---
|
||||
|
||||
## 리소스/성능 메모
|
||||
|
||||
* HF 카드 기준 모델은 약 **3B 파라미터, BF16**, GPU 구동 전제 예시가 제공됩니다(Transformers 섹션). CPU도 이론상 가능하나 실사용 속도는 크게 떨어질 수 있어요. vLLM 경로는 대용량 처리에 유리. ([Hugging Face][2])
|
||||
* 리드미에는 **A100-40G에서 PDF 처리 체감치(토큰/s)** 예시가 실려 있습니다. ([GitHub][1])
|
||||
|
||||
---
|
||||
|
||||
## 보안/정책 유의(특히 기업 환경)
|
||||
|
||||
일부 공공기관·기업은 DeepSeek 계열 모델 사용을 제한하기도 합니다. 예컨대 **미 상무부 일부 국·실국은 2025-03-17 공지로 DeepSeek 사용 금지**를 안내했습니다. 회사 정책과 고객사 보안요건을 먼저 확인하세요. ([Reuters][5])
|
||||
|
||||
---
|
||||
|
||||
## 공식 문서/레퍼런스
|
||||
|
||||
* GitHub(설치·스크립트·프롬프트·PDF 처리): ([GitHub][1])
|
||||
* Hugging Face 모델 카드(버전 고정·vLLM 예시): ([Hugging Face][2])
|
||||
* vLLM 가이드(오프라인 배치 레시피): ([VLLM Documentation][6])
|
||||
* 연구/아티클(컨텍스트 압축 아이디어): ([arXiv][7])
|
||||
|
||||
---
|
||||
|
||||
### 다음 선택지
|
||||
|
||||
* 운영 관점: **배치 파이프라인(PDF 폴더→MD/JSON)** 또는 **REST API** 중 하나로 고정.
|
||||
* 프롬프트/후처리: 표/수식/도형은 “Convert to markdown” + 후처리(표 파서, 수식 라텍스 보정) 조합이 안정적.
|
||||
* 우리 스택(Nginx/Apache + PHP/Laravel + Jenkins)에서라면: **별도 GPU 호스트 + FastAPI 서비스**로 세워 두고, Admin/API에서 비동기 잡 큐로 호출하는 구조가 깔끔합니다.
|
||||
|
||||
원하시면 **PDF 폴더 일괄 변환 스크립트**(입력/출력 디렉토리, 동시성, 실패 리트라이 포함)나 **FastAPI 라우터 샘플**까지 바로 짜드릴게요.
|
||||
|
||||
[1]: https://github.com/deepseek-ai/DeepSeek-OCR "GitHub - deepseek-ai/DeepSeek-OCR: Contexts Optical Compression"
|
||||
[2]: https://huggingface.co/deepseek-ai/DeepSeek-OCR "deepseek-ai/DeepSeek-OCR · Hugging Face"
|
||||
[3]: https://github.com/Bogdanovich77/DeekSeek-OCR---Dockerized-API?utm_source=chatgpt.com "Bogdanovich77/DeekSeek-OCR---Dockerized-API"
|
||||
[4]: https://github.com/TimmyOVO/deepseek-ocr.rs?utm_source=chatgpt.com "TimmyOVO/deepseek-ocr.rs"
|
||||
[5]: https://www.reuters.com/technology/artificial-intelligence/us-commerce-department-bureaus-ban-chinas-deepseek-government-devices-sources-2025-03-17/?utm_source=chatgpt.com "US Commerce department bureaus ban China's DeepSeek on government devices, sources say"
|
||||
[6]: https://docs.vllm.ai/projects/recipes/en/latest/DeepSeek/DeepSeek-OCR.html?utm_source=chatgpt.com "DeepSeek-OCR Usage Guide - vLLM Recipes"
|
||||
[7]: https://arxiv.org/html/2510.18234v1?utm_source=chatgpt.com "DeepSeek-OCR: Contexts Optical Compression"
|
||||
|
||||
|
||||
|
||||
|
||||
질문 :
|
||||
사업자등록증 pdf를 업로드 해서 AI를 활용한 OCR 기술로 그 사업자 등록증의 형태에 맞게 웹이나 앱에서 이 입력부분을 자동으로 처리하는 것을 만들려고 한다. 만약 php와 자바스크립트를 이용해서 이 로직구현이 가능할까? 추후 python등의 서버를 구비해서 API형태로 만들고 싶다.
|
||||
|
||||
|
||||
AI 답변 :
|
||||
충분히 됩니다. 카페24 **웹호스팅(PHP 7.3 / MySQL 8.0)** 기준으로, 서버에 바이너리를 깔 수 없으니 **브라우저에서 Tesseract.js + pdf.js**로 OCR을 돌리고, **추출 결과만** PHP로 저장하는 구조가 가장 현실적이에요. 그대로 가져다 쓸 수 있게 “파일 단위”로 정리했습니다.
|
||||
|
||||
---
|
||||
|
||||
# 0) DB 테이블 (MySQL 8.0)
|
||||
|
||||
```sql
|
||||
CREATE TABLE biz_cert (
|
||||
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
|
||||
biz_no VARCHAR(20) NOT NULL, -- 000-00-00000
|
||||
company_name VARCHAR(255) NOT NULL,
|
||||
representative VARCHAR(100) NOT NULL,
|
||||
open_date DATE NULL,
|
||||
address TEXT NULL,
|
||||
type VARCHAR(255) NULL,
|
||||
item VARCHAR(255) NULL,
|
||||
issue_date DATE NULL,
|
||||
raw_text LONGTEXT NULL, -- 원문 텍스트(디버그용)
|
||||
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
PRIMARY KEY (id),
|
||||
KEY idx_biz_no (biz_no)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
# 1) `/config/db.php` (PDO 연결 공통)
|
||||
|
||||
```php
|
||||
<?php
|
||||
// /config/db.php
|
||||
define('DB_HOST', 'localhost');
|
||||
define('DB_NAME', 'YOUR_DB_NAME');
|
||||
define('DB_USER', 'YOUR_DB_USER');
|
||||
define('DB_PASS', 'YOUR_DB_PASS');
|
||||
define('DB_CHARSET', 'utf8mb4');
|
||||
|
||||
function db_connect(): PDO {
|
||||
$dsn = 'mysql:host='.DB_HOST.';dbname='.DB_NAME.';charset='.DB_CHARSET;
|
||||
$opt = [
|
||||
PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION,
|
||||
PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC,
|
||||
];
|
||||
return new PDO($dsn, DB_USER, DB_PASS, $opt);
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
# 2) `/public/index.html` (업로드→브라우저 OCR→자동채움→저장)
|
||||
|
||||
```html
|
||||
<!doctype html>
|
||||
<html lang="ko">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width,initial-scale=1" />
|
||||
<title>사업자등록증 OCR (MVP, PHP7.3/MySQL8)</title>
|
||||
<style>
|
||||
body{font:14px/1.4 system-ui,Segoe UI,Apple SD Gothic Neo,Malgun Gothic}
|
||||
.row{display:flex;gap:24px}
|
||||
.col{flex:1}
|
||||
#preview{max-width:100%;border:1px solid #ddd}
|
||||
.hl{background:#fff7cc}
|
||||
.mono{font-family:ui-monospace,Consolas,monospace}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<h2>사업자등록증 OCR (AI 없이, 브라우저 Tesseract.js)</h2>
|
||||
|
||||
<input id="file" type="file" accept="application/pdf,image/*" />
|
||||
<span id="status">대기중</span>
|
||||
<div class="row" style="margin-top:16px">
|
||||
<div class="col">
|
||||
<canvas id="pdf-canvas" style="display:none"></canvas>
|
||||
<img id="preview" alt="미리보기" />
|
||||
<pre id="raw" class="mono" style="white-space:pre-wrap;background:#f7f7f7;padding:8px"></pre>
|
||||
</div>
|
||||
<div class="col">
|
||||
<form id="biz-form">
|
||||
<p><input class="hl" name="biz_no" placeholder="사업자등록번호 (000-00-00000)" style="width:100%"></p>
|
||||
<p><input class="hl" name="company_name" placeholder="상호" style="width:100%"></p>
|
||||
<p><input class="hl" name="representative" placeholder="대표자" style="width:100%"></p>
|
||||
<p><input class="hl" name="open_date" placeholder="개업연월일 (YYYY-MM-DD)" style="width:100%"></p>
|
||||
<p><input class="hl" name="address" placeholder="사업장 소재지" style="width:100%"></p>
|
||||
<p><input class="hl" name="type" placeholder="업태" style="width:100%"></p>
|
||||
<p><input class="hl" name="item" placeholder="종목" style="width:100%"></p>
|
||||
<p><input name="issue_date" placeholder="발급일자 (선택)" style="width:100%"></p>
|
||||
<button type="button" id="save">저장</button>
|
||||
</form>
|
||||
<div id="save-status"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- pdf.js (브라우저에서 PDF → canvas 렌더링) -->
|
||||
<script src="https://cdn.jsdelivr.net/npm/pdfjs-dist@4.6.82/build/pdf.min.js"></script>
|
||||
<script>pdfjsLib.GlobalWorkerOptions.workerSrc="https://cdn.jsdelivr.net/npm/pdfjs-dist@4.6.82/build/pdf.worker.min.js";</script>
|
||||
|
||||
<!-- Tesseract.js (브라우저 OCR) -->
|
||||
<script src="https://cdn.jsdelivr.net/npm/tesseract.js@5/dist/tesseract.min.js"></script>
|
||||
|
||||
<script>
|
||||
const $ = (q)=>document.querySelector(q);
|
||||
const statusEl = $('#status');
|
||||
const rawEl = $('#raw');
|
||||
const previewEl = $('#preview');
|
||||
const pdfCanvas = $('#pdf-canvas');
|
||||
|
||||
function normalizeBiz(v){
|
||||
const d=(v||'').replace(/\D/g,'');
|
||||
if(d.length===10) return d.slice(0,3)+'-'+d.slice(3,5)+'-'+d.slice(5);
|
||||
return v||'';
|
||||
}
|
||||
function isValidBizNo(v){
|
||||
const d=(v||'').replace(/\D/g,''); if(d.length!==10) return false;
|
||||
const w=[1,3,7,1,3,7,1,3,5]; let sum=0;
|
||||
for(let i=0;i<9;i++) sum += Number(d[i])*w[i];
|
||||
sum += Math.floor((Number(d[8])*5)/10);
|
||||
const check=(10-(sum%10))%10; return check===Number(d[9]);
|
||||
}
|
||||
function toDateISO(s){
|
||||
if(!s) return '';
|
||||
const d=s.replace(/[년월.\-\/\s]/g,'-').replace(/-+/g,'-').replace(/-$/, '');
|
||||
const m=d.match(/(\d{4})-(\d{1,2})-(\d{1,2})/);
|
||||
return m ? `${m[1]}-${m[2].padStart(2,'0')}-${m[3].padStart(2,'0')}` : '';
|
||||
}
|
||||
|
||||
// 간단 파서 (OCR 텍스트 → 필드)
|
||||
function parseBiz(text){
|
||||
const T=text||'';
|
||||
const pick=(re)=>{const m=T.match(re); return m?m[1].trim():''};
|
||||
|
||||
// 앵커 기반
|
||||
let biz = pick(/사업자등록번호[^\d]*(\d{3}[\s\-]?\d{2}[\s\-]?\d{5})/u);
|
||||
let company = pick(/상\s*호[^\S\r\n]*[::]?\s*([^\r\n]+)/u);
|
||||
let rep = pick(/대표자[^\S\r\n]*[::]?\s*([^\r\n]+)/u);
|
||||
let open = pick(/개업[^\r\n]*(\d{4}[.\-년]\s*\d{1,2}[.\-월]?\s*\d{1,2})/u);
|
||||
let addr = pick(/사업장\s*소재지[^\S\r\n]*[::]?\s*([^\r\n]+(?:\r?\n[^\r\n]+)?)/u);
|
||||
let type = pick(/업태[^\S\r\n]*[::]?\s*([^\r\n]+)/u);
|
||||
let item = pick(/종목[^\S\r\n]*[::]?\s*([^\r\n]+)/u);
|
||||
let issue = pick(/발급일자[^\d]*(\d{4}[.\-\/]\s*\d{1,2}[.\-\/]?\s*\d{1,2})/u);
|
||||
|
||||
biz = normalizeBiz(biz);
|
||||
open = toDateISO(open);
|
||||
issue = toDateISO(issue);
|
||||
|
||||
return { biz_no: biz, company_name: company, representative: rep,
|
||||
open_date: open, address: addr, type, item, issue_date: issue };
|
||||
}
|
||||
|
||||
// PDF → canvas → dataURL
|
||||
async function pdfFirstPageToDataURL(file){
|
||||
const buf = await file.arrayBuffer();
|
||||
const pdf = await pdfjsLib.getDocument({data:buf}).promise;
|
||||
const page = await pdf.getPage(1);
|
||||
const viewport = page.getViewport({scale: 2.0});
|
||||
const canvas = pdfCanvas; const ctx = canvas.getContext('2d');
|
||||
canvas.width = viewport.width; canvas.height = viewport.height;
|
||||
await page.render({canvasContext:ctx, viewport}).promise;
|
||||
return canvas.toDataURL('image/png');
|
||||
}
|
||||
|
||||
async function runOCR(imageDataURL){
|
||||
statusEl.textContent = 'OCR 시작...';
|
||||
const { data } = await Tesseract.recognize(
|
||||
imageDataURL,
|
||||
'kor+eng',
|
||||
{
|
||||
logger: m => { if(m.status==='recognizing text'){ statusEl.textContent = `인식중... ${Math.round(m.progress*100)}%`; } },
|
||||
// 학습 데이터 경로 (공개 CDN)
|
||||
langPath: 'https://tessdata.projectnaptha.com/4.0.0'
|
||||
}
|
||||
);
|
||||
statusEl.textContent = 'OCR 완료';
|
||||
return data.text || '';
|
||||
}
|
||||
|
||||
$('#file').addEventListener('change', async (e)=>{
|
||||
const f = e.target.files[0]; if(!f) return;
|
||||
rawEl.textContent = ''; statusEl.textContent='준비중...';
|
||||
|
||||
let imageDataURL=null;
|
||||
|
||||
if(f.type==='application/pdf'){
|
||||
imageDataURL = await pdfFirstPageToDataURL(f); // 1페이지만 예시
|
||||
previewEl.src = imageDataURL;
|
||||
} else if(f.type.startsWith('image/')){
|
||||
imageDataURL = URL.createObjectURL(f);
|
||||
previewEl.src = imageDataURL;
|
||||
} else {
|
||||
statusEl.textContent = '지원하지 않는 형식';
|
||||
return;
|
||||
}
|
||||
|
||||
const text = await runOCR(imageDataURL);
|
||||
rawEl.textContent = text;
|
||||
|
||||
const fields = parseBiz(text);
|
||||
for(const k in fields){
|
||||
const el=document.querySelector(`[name="${k}"]`);
|
||||
if(el) el.value = fields[k] || '';
|
||||
}
|
||||
|
||||
// 즉시 유효성 힌트
|
||||
const biz = document.querySelector('[name="biz_no"]').value;
|
||||
if(biz && !isValidBizNo(biz)) statusEl.textContent = '참고: 사업자번호 체크섬 불일치(수정 검토)';
|
||||
});
|
||||
|
||||
$('#save').addEventListener('click', async ()=>{
|
||||
const form = $('#biz-form'); const obj = {};
|
||||
new FormData(form).forEach((v,k)=>obj[k]=v);
|
||||
obj['raw_text'] = rawEl.textContent;
|
||||
|
||||
const res = await fetch('/save_biz.php', {
|
||||
method:'POST',
|
||||
headers:{'Content-Type':'application/json'},
|
||||
body: JSON.stringify(obj)
|
||||
});
|
||||
const data = await res.json();
|
||||
$('#save-status').textContent = data.ok ? `저장 완료 (id=${data.id})` : `실패: ${data.error||'unknown'}`;
|
||||
});
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
```
|
||||
|
||||
> 포인트
|
||||
>
|
||||
> * **PDF도 브라우저에서 1페이지만 렌더** → 이미지처럼 OCR. (여러 페이지면 반복 처리)
|
||||
> * **언어팩**: `kor+eng`, `langPath`는 공개 CDN 사용.
|
||||
> * **정규식 파싱**은 앵커(“사업자등록번호”, “상호”… ) 기반이라 양식이 조금 달라도 견딥니다.
|
||||
|
||||
---
|
||||
|
||||
# 3) `/save_biz.php` (추출 결과 저장 API)
|
||||
|
||||
```php
|
||||
<?php
|
||||
// /save_biz.php
|
||||
header('Content-Type: application/json; charset=utf-8');
|
||||
require_once __DIR__.'/config/db.php';
|
||||
|
||||
function normalize_biz($v){
|
||||
$d = preg_replace('/\D/','',$v??'');
|
||||
return (strlen($d)===10) ? substr($d,0,3).'-'.substr($d,3,2).'-'.substr($d,5) : ($v??'');
|
||||
}
|
||||
function valid_biz_no($s){
|
||||
$d = preg_replace('/\D/','',$s??'');
|
||||
if(strlen($d)!=10) return false;
|
||||
$w=[1,3,7,1,3,7,1,3,5]; $sum=0;
|
||||
for($i=0;$i<9;$i++){ $sum += intval($d[$i])*$w[$i]; }
|
||||
$sum += intdiv(intval($d[8])*5,10);
|
||||
$check = (10 - ($sum % 10)) % 10;
|
||||
return $check == intval($d[9]);
|
||||
}
|
||||
function to_date($s){
|
||||
if(!$s) return null;
|
||||
$d = preg_replace('/[년월.\-\/\s]+/','-',$s);
|
||||
if(preg_match('/^(\d{4})-(\d{1,2})-(\d{1,2})$/',$d,$m)){
|
||||
return sprintf('%04d-%02d-%02d',$m[1],$m[2],$m[3]);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
$input = json_decode(file_get_contents('php://input'), true) ?: [];
|
||||
|
||||
$biz_no = normalize_biz($input['biz_no'] ?? '');
|
||||
$company_name = trim($input['company_name'] ?? '');
|
||||
$representative = trim($input['representative'] ?? '');
|
||||
$open_date = to_date($input['open_date'] ?? '');
|
||||
$address = trim($input['address'] ?? '');
|
||||
$type = trim($input['type'] ?? '');
|
||||
$item = trim($input['item'] ?? '');
|
||||
$issue_date = to_date($input['issue_date'] ?? '');
|
||||
$raw_text = $input['raw_text'] ?? '';
|
||||
|
||||
if(!$biz_no || !$company_name){
|
||||
echo json_encode(['ok'=>false,'error'=>'필수값 누락(biz_no/company_name)']); exit;
|
||||
}
|
||||
|
||||
try{
|
||||
$pdo = db_connect();
|
||||
$stmt = $pdo->prepare("
|
||||
INSERT INTO biz_cert
|
||||
(biz_no, company_name, representative, open_date, address, type, item, issue_date, raw_text)
|
||||
VALUES (:biz_no, :company_name, :representative, :open_date, :address, :type, :item, :issue_date, :raw_text)
|
||||
");
|
||||
$stmt->execute([
|
||||
':biz_no'=>$biz_no,
|
||||
':company_name'=>$company_name,
|
||||
':representative'=>$representative,
|
||||
':open_date'=>$open_date,
|
||||
':address'=>$address,
|
||||
':type'=>$type,
|
||||
':item'=>$item,
|
||||
':issue_date'=>$issue_date,
|
||||
':raw_text'=>$raw_text,
|
||||
]);
|
||||
echo json_encode(['ok'=>true,'id'=>$pdo->lastInsertId(),'valid_biz_no'=>valid_biz_no($biz_no)]);
|
||||
} catch(Throwable $e){
|
||||
http_response_code(500);
|
||||
echo json_encode(['ok'=>false,'error'=>$e->getMessage()]);
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
# 4) `/list.php` (최근 20건 확인용)
|
||||
|
||||
```php
|
||||
<?php
|
||||
// /list.php
|
||||
header('Content-Type: text/html; charset=utf-8');
|
||||
require_once __DIR__.'/config/db.php';
|
||||
$pdo = db_connect();
|
||||
$rows = $pdo->query("SELECT id,biz_no,company_name,representative,open_date,created_at FROM biz_cert ORDER BY id DESC LIMIT 20")->fetchAll();
|
||||
?>
|
||||
<!doctype html><meta charset="utf-8">
|
||||
<h3>최근 20건</h3>
|
||||
<table border="1" cellpadding="6" cellspacing="0">
|
||||
<tr><th>ID</th><th>사업자번호</th><th>상호</th><th>대표자</th><th>개업일</th><th>저장일</th></tr>
|
||||
<?php foreach($rows as $r): ?>
|
||||
<tr>
|
||||
<td><?=htmlspecialchars($r['id'])?></td>
|
||||
<td><?=htmlspecialchars($r['biz_no'])?></td>
|
||||
<td><?=htmlspecialchars($r['company_name'])?></td>
|
||||
<td><?=htmlspecialchars($r['representative'])?></td>
|
||||
<td><?=htmlspecialchars($r['open_date'])?></td>
|
||||
<td><?=htmlspecialchars($r['created_at'])?></td>
|
||||
</tr>
|
||||
<?php endforeach; ?>
|
||||
</table>
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 운영 팁 (카페24 웹호스팅 한정)
|
||||
|
||||
* **서버 exec 미사용**: Tesseract, Poppler 같은 바이너리 설치 불가하므로 브라우저 OCR이 현실적.
|
||||
* **용량/속도**: PDF가 크면 1페이지만 먼저 처리 → “추가 페이지 인식” 버튼으로 확장.
|
||||
* **언어팩 캐시**: Tesseract.js가 언어 데이터(약 수~수십 MB)를 내려받습니다. CDN 캐시 이점이 큼.
|
||||
* **정확도 올리기**: PDF 렌더 스케일을 `2.0→2.5~3.0`으로, 이미지면 `<img>`를 `<canvas>`로 리샘플 후 OCR.
|
||||
* **보정 UX**: 자동 채움 후 노란 배경(샘플처럼 `.hl`) + 탭 이동 → 저장.
|
||||
* **보안**: 저장 전 **숫자 마스킹**(예: `대표자 주민번호 일부`)이 필요하면 프런트/백 모두에서 처리.
|
||||
|
||||
---
|
||||
|
||||
## 다음 단계(나중에 고도화)
|
||||
|
||||
* Python API(DeepSeek-OCR/PaddleOCR) 별도 서버 → 같은 방식으로 **/save_biz.php** 앞단에서 교체 호출.
|
||||
* 앵커 크롭 후 2차 OCR(속도/정확도↑), 주소 정규화, 체크섬 실패 시 재인식 루틴 등.
|
||||
|
||||
필요하면 **여러 페이지 PDF 지원**, **라라벨 컨트롤러 버전**, **Docker-Compose 모식도**, **Nginx 리버스 프록시 샘플**까지 이어서 드릴게요.
|
||||
|
||||
|
||||
질문 :
|
||||
카페24에서 웹호스팅을 해서 사용하는 사이트가 있는데, 위의 라이브러리를 사용해서 직접 구현이 가능하게 단계별 코드를 만들어줘. php 7.3 mysql 8.0 기준으로 보면됩니다.
|
||||
|
||||
답변 :
|
||||
충분히 됩니다. 카페24 **웹호스팅(PHP 7.3 / MySQL 8.0)** 기준으로, 서버에 바이너리를 깔 수 없으니 **브라우저에서 Tesseract.js + pdf.js**로 OCR을 돌리고, **추출 결과만** PHP로 저장하는 구조가 가장 현실적이에요. 그대로 가져다 쓸 수 있게 “파일 단위”로 정리했습니다.
|
||||
|
||||
---
|
||||
|
||||
# 0) DB 테이블 (MySQL 8.0)
|
||||
|
||||
```sql
|
||||
CREATE TABLE biz_cert (
|
||||
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
|
||||
biz_no VARCHAR(20) NOT NULL, -- 000-00-00000
|
||||
company_name VARCHAR(255) NOT NULL,
|
||||
representative VARCHAR(100) NOT NULL,
|
||||
open_date DATE NULL,
|
||||
address TEXT NULL,
|
||||
type VARCHAR(255) NULL,
|
||||
item VARCHAR(255) NULL,
|
||||
issue_date DATE NULL,
|
||||
raw_text LONGTEXT NULL, -- 원문 텍스트(디버그용)
|
||||
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
PRIMARY KEY (id),
|
||||
KEY idx_biz_no (biz_no)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
# 1) `/config/db.php` (PDO 연결 공통)
|
||||
|
||||
```php
|
||||
<?php
|
||||
// /config/db.php
|
||||
define('DB_HOST', 'localhost');
|
||||
define('DB_NAME', 'YOUR_DB_NAME');
|
||||
define('DB_USER', 'YOUR_DB_USER');
|
||||
define('DB_PASS', 'YOUR_DB_PASS');
|
||||
define('DB_CHARSET', 'utf8mb4');
|
||||
|
||||
function db_connect(): PDO {
|
||||
$dsn = 'mysql:host='.DB_HOST.';dbname='.DB_NAME.';charset='.DB_CHARSET;
|
||||
$opt = [
|
||||
PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION,
|
||||
PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC,
|
||||
];
|
||||
return new PDO($dsn, DB_USER, DB_PASS, $opt);
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
# 2) `/public/index.html` (업로드→브라우저 OCR→자동채움→저장)
|
||||
|
||||
```html
|
||||
<!doctype html>
|
||||
<html lang="ko">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width,initial-scale=1" />
|
||||
<title>사업자등록증 OCR (MVP, PHP7.3/MySQL8)</title>
|
||||
<style>
|
||||
body{font:14px/1.4 system-ui,Segoe UI,Apple SD Gothic Neo,Malgun Gothic}
|
||||
.row{display:flex;gap:24px}
|
||||
.col{flex:1}
|
||||
#preview{max-width:100%;border:1px solid #ddd}
|
||||
.hl{background:#fff7cc}
|
||||
.mono{font-family:ui-monospace,Consolas,monospace}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<h2>사업자등록증 OCR (AI 없이, 브라우저 Tesseract.js)</h2>
|
||||
|
||||
<input id="file" type="file" accept="application/pdf,image/*" />
|
||||
<span id="status">대기중</span>
|
||||
<div class="row" style="margin-top:16px">
|
||||
<div class="col">
|
||||
<canvas id="pdf-canvas" style="display:none"></canvas>
|
||||
<img id="preview" alt="미리보기" />
|
||||
<pre id="raw" class="mono" style="white-space:pre-wrap;background:#f7f7f7;padding:8px"></pre>
|
||||
</div>
|
||||
<div class="col">
|
||||
<form id="biz-form">
|
||||
<p><input class="hl" name="biz_no" placeholder="사업자등록번호 (000-00-00000)" style="width:100%"></p>
|
||||
<p><input class="hl" name="company_name" placeholder="상호" style="width:100%"></p>
|
||||
<p><input class="hl" name="representative" placeholder="대표자" style="width:100%"></p>
|
||||
<p><input class="hl" name="open_date" placeholder="개업연월일 (YYYY-MM-DD)" style="width:100%"></p>
|
||||
<p><input class="hl" name="address" placeholder="사업장 소재지" style="width:100%"></p>
|
||||
<p><input class="hl" name="type" placeholder="업태" style="width:100%"></p>
|
||||
<p><input class="hl" name="item" placeholder="종목" style="width:100%"></p>
|
||||
<p><input name="issue_date" placeholder="발급일자 (선택)" style="width:100%"></p>
|
||||
<button type="button" id="save">저장</button>
|
||||
</form>
|
||||
<div id="save-status"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- pdf.js (브라우저에서 PDF → canvas 렌더링) -->
|
||||
<script src="https://cdn.jsdelivr.net/npm/pdfjs-dist@4.6.82/build/pdf.min.js"></script>
|
||||
<script>pdfjsLib.GlobalWorkerOptions.workerSrc="https://cdn.jsdelivr.net/npm/pdfjs-dist@4.6.82/build/pdf.worker.min.js";</script>
|
||||
|
||||
<!-- Tesseract.js (브라우저 OCR) -->
|
||||
<script src="https://cdn.jsdelivr.net/npm/tesseract.js@5/dist/tesseract.min.js"></script>
|
||||
|
||||
<script>
|
||||
const $ = (q)=>document.querySelector(q);
|
||||
const statusEl = $('#status');
|
||||
const rawEl = $('#raw');
|
||||
const previewEl = $('#preview');
|
||||
const pdfCanvas = $('#pdf-canvas');
|
||||
|
||||
function normalizeBiz(v){
|
||||
const d=(v||'').replace(/\D/g,'');
|
||||
if(d.length===10) return d.slice(0,3)+'-'+d.slice(3,5)+'-'+d.slice(5);
|
||||
return v||'';
|
||||
}
|
||||
function isValidBizNo(v){
|
||||
const d=(v||'').replace(/\D/g,''); if(d.length!==10) return false;
|
||||
const w=[1,3,7,1,3,7,1,3,5]; let sum=0;
|
||||
for(let i=0;i<9;i++) sum += Number(d[i])*w[i];
|
||||
sum += Math.floor((Number(d[8])*5)/10);
|
||||
const check=(10-(sum%10))%10; return check===Number(d[9]);
|
||||
}
|
||||
function toDateISO(s){
|
||||
if(!s) return '';
|
||||
const d=s.replace(/[년월.\-\/\s]/g,'-').replace(/-+/g,'-').replace(/-$/, '');
|
||||
const m=d.match(/(\d{4})-(\d{1,2})-(\d{1,2})/);
|
||||
return m ? `${m[1]}-${m[2].padStart(2,'0')}-${m[3].padStart(2,'0')}` : '';
|
||||
}
|
||||
|
||||
// 간단 파서 (OCR 텍스트 → 필드)
|
||||
function parseBiz(text){
|
||||
const T=text||'';
|
||||
const pick=(re)=>{const m=T.match(re); return m?m[1].trim():''};
|
||||
|
||||
// 앵커 기반
|
||||
let biz = pick(/사업자등록번호[^\d]*(\d{3}[\s\-]?\d{2}[\s\-]?\d{5})/u);
|
||||
let company = pick(/상\s*호[^\S\r\n]*[::]?\s*([^\r\n]+)/u);
|
||||
let rep = pick(/대표자[^\S\r\n]*[::]?\s*([^\r\n]+)/u);
|
||||
let open = pick(/개업[^\r\n]*(\d{4}[.\-년]\s*\d{1,2}[.\-월]?\s*\d{1,2})/u);
|
||||
let addr = pick(/사업장\s*소재지[^\S\r\n]*[::]?\s*([^\r\n]+(?:\r?\n[^\r\n]+)?)/u);
|
||||
let type = pick(/업태[^\S\r\n]*[::]?\s*([^\r\n]+)/u);
|
||||
let item = pick(/종목[^\S\r\n]*[::]?\s*([^\r\n]+)/u);
|
||||
let issue = pick(/발급일자[^\d]*(\d{4}[.\-\/]\s*\d{1,2}[.\-\/]?\s*\d{1,2})/u);
|
||||
|
||||
biz = normalizeBiz(biz);
|
||||
open = toDateISO(open);
|
||||
issue = toDateISO(issue);
|
||||
|
||||
return { biz_no: biz, company_name: company, representative: rep,
|
||||
open_date: open, address: addr, type, item, issue_date: issue };
|
||||
}
|
||||
|
||||
// PDF → canvas → dataURL
|
||||
async function pdfFirstPageToDataURL(file){
|
||||
const buf = await file.arrayBuffer();
|
||||
const pdf = await pdfjsLib.getDocument({data:buf}).promise;
|
||||
const page = await pdf.getPage(1);
|
||||
const viewport = page.getViewport({scale: 2.0});
|
||||
const canvas = pdfCanvas; const ctx = canvas.getContext('2d');
|
||||
canvas.width = viewport.width; canvas.height = viewport.height;
|
||||
await page.render({canvasContext:ctx, viewport}).promise;
|
||||
return canvas.toDataURL('image/png');
|
||||
}
|
||||
|
||||
async function runOCR(imageDataURL){
|
||||
statusEl.textContent = 'OCR 시작...';
|
||||
const { data } = await Tesseract.recognize(
|
||||
imageDataURL,
|
||||
'kor+eng',
|
||||
{
|
||||
logger: m => { if(m.status==='recognizing text'){ statusEl.textContent = `인식중... ${Math.round(m.progress*100)}%`; } },
|
||||
// 학습 데이터 경로 (공개 CDN)
|
||||
langPath: 'https://tessdata.projectnaptha.com/4.0.0'
|
||||
}
|
||||
);
|
||||
statusEl.textContent = 'OCR 완료';
|
||||
return data.text || '';
|
||||
}
|
||||
|
||||
$('#file').addEventListener('change', async (e)=>{
|
||||
const f = e.target.files[0]; if(!f) return;
|
||||
rawEl.textContent = ''; statusEl.textContent='준비중...';
|
||||
|
||||
let imageDataURL=null;
|
||||
|
||||
if(f.type==='application/pdf'){
|
||||
imageDataURL = await pdfFirstPageToDataURL(f); // 1페이지만 예시
|
||||
previewEl.src = imageDataURL;
|
||||
} else if(f.type.startsWith('image/')){
|
||||
imageDataURL = URL.createObjectURL(f);
|
||||
previewEl.src = imageDataURL;
|
||||
} else {
|
||||
statusEl.textContent = '지원하지 않는 형식';
|
||||
return;
|
||||
}
|
||||
|
||||
const text = await runOCR(imageDataURL);
|
||||
rawEl.textContent = text;
|
||||
|
||||
const fields = parseBiz(text);
|
||||
for(const k in fields){
|
||||
const el=document.querySelector(`[name="${k}"]`);
|
||||
if(el) el.value = fields[k] || '';
|
||||
}
|
||||
|
||||
// 즉시 유효성 힌트
|
||||
const biz = document.querySelector('[name="biz_no"]').value;
|
||||
if(biz && !isValidBizNo(biz)) statusEl.textContent = '참고: 사업자번호 체크섬 불일치(수정 검토)';
|
||||
});
|
||||
|
||||
$('#save').addEventListener('click', async ()=>{
|
||||
const form = $('#biz-form'); const obj = {};
|
||||
new FormData(form).forEach((v,k)=>obj[k]=v);
|
||||
obj['raw_text'] = rawEl.textContent;
|
||||
|
||||
const res = await fetch('/save_biz.php', {
|
||||
method:'POST',
|
||||
headers:{'Content-Type':'application/json'},
|
||||
body: JSON.stringify(obj)
|
||||
});
|
||||
const data = await res.json();
|
||||
$('#save-status').textContent = data.ok ? `저장 완료 (id=${data.id})` : `실패: ${data.error||'unknown'}`;
|
||||
});
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
```
|
||||
|
||||
> 포인트
|
||||
>
|
||||
> * **PDF도 브라우저에서 1페이지만 렌더** → 이미지처럼 OCR. (여러 페이지면 반복 처리)
|
||||
> * **언어팩**: `kor+eng`, `langPath`는 공개 CDN 사용.
|
||||
> * **정규식 파싱**은 앵커(“사업자등록번호”, “상호”… ) 기반이라 양식이 조금 달라도 견딥니다.
|
||||
|
||||
---
|
||||
|
||||
# 3) `/save_biz.php` (추출 결과 저장 API)
|
||||
|
||||
```php
|
||||
<?php
|
||||
// /save_biz.php
|
||||
header('Content-Type: application/json; charset=utf-8');
|
||||
require_once __DIR__.'/config/db.php';
|
||||
|
||||
function normalize_biz($v){
|
||||
$d = preg_replace('/\D/','',$v??'');
|
||||
return (strlen($d)===10) ? substr($d,0,3).'-'.substr($d,3,2).'-'.substr($d,5) : ($v??'');
|
||||
}
|
||||
function valid_biz_no($s){
|
||||
$d = preg_replace('/\D/','',$s??'');
|
||||
if(strlen($d)!=10) return false;
|
||||
$w=[1,3,7,1,3,7,1,3,5]; $sum=0;
|
||||
for($i=0;$i<9;$i++){ $sum += intval($d[$i])*$w[$i]; }
|
||||
$sum += intdiv(intval($d[8])*5,10);
|
||||
$check = (10 - ($sum % 10)) % 10;
|
||||
return $check == intval($d[9]);
|
||||
}
|
||||
function to_date($s){
|
||||
if(!$s) return null;
|
||||
$d = preg_replace('/[년월.\-\/\s]+/','-',$s);
|
||||
if(preg_match('/^(\d{4})-(\d{1,2})-(\d{1,2})$/',$d,$m)){
|
||||
return sprintf('%04d-%02d-%02d',$m[1],$m[2],$m[3]);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
$input = json_decode(file_get_contents('php://input'), true) ?: [];
|
||||
|
||||
$biz_no = normalize_biz($input['biz_no'] ?? '');
|
||||
$company_name = trim($input['company_name'] ?? '');
|
||||
$representative = trim($input['representative'] ?? '');
|
||||
$open_date = to_date($input['open_date'] ?? '');
|
||||
$address = trim($input['address'] ?? '');
|
||||
$type = trim($input['type'] ?? '');
|
||||
$item = trim($input['item'] ?? '');
|
||||
$issue_date = to_date($input['issue_date'] ?? '');
|
||||
$raw_text = $input['raw_text'] ?? '';
|
||||
|
||||
if(!$biz_no || !$company_name){
|
||||
echo json_encode(['ok'=>false,'error'=>'필수값 누락(biz_no/company_name)']); exit;
|
||||
}
|
||||
|
||||
try{
|
||||
$pdo = db_connect();
|
||||
$stmt = $pdo->prepare("
|
||||
INSERT INTO biz_cert
|
||||
(biz_no, company_name, representative, open_date, address, type, item, issue_date, raw_text)
|
||||
VALUES (:biz_no, :company_name, :representative, :open_date, :address, :type, :item, :issue_date, :raw_text)
|
||||
");
|
||||
$stmt->execute([
|
||||
':biz_no'=>$biz_no,
|
||||
':company_name'=>$company_name,
|
||||
':representative'=>$representative,
|
||||
':open_date'=>$open_date,
|
||||
':address'=>$address,
|
||||
':type'=>$type,
|
||||
':item'=>$item,
|
||||
':issue_date'=>$issue_date,
|
||||
':raw_text'=>$raw_text,
|
||||
]);
|
||||
echo json_encode(['ok'=>true,'id'=>$pdo->lastInsertId(),'valid_biz_no'=>valid_biz_no($biz_no)]);
|
||||
} catch(Throwable $e){
|
||||
http_response_code(500);
|
||||
echo json_encode(['ok'=>false,'error'=>$e->getMessage()]);
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
# 4) `/list.php` (최근 20건 확인용)
|
||||
|
||||
```php
|
||||
<?php
|
||||
// /list.php
|
||||
header('Content-Type: text/html; charset=utf-8');
|
||||
require_once __DIR__.'/config/db.php';
|
||||
$pdo = db_connect();
|
||||
$rows = $pdo->query("SELECT id,biz_no,company_name,representative,open_date,created_at FROM biz_cert ORDER BY id DESC LIMIT 20")->fetchAll();
|
||||
?>
|
||||
<!doctype html><meta charset="utf-8">
|
||||
<h3>최근 20건</h3>
|
||||
<table border="1" cellpadding="6" cellspacing="0">
|
||||
<tr><th>ID</th><th>사업자번호</th><th>상호</th><th>대표자</th><th>개업일</th><th>저장일</th></tr>
|
||||
<?php foreach($rows as $r): ?>
|
||||
<tr>
|
||||
<td><?=htmlspecialchars($r['id'])?></td>
|
||||
<td><?=htmlspecialchars($r['biz_no'])?></td>
|
||||
<td><?=htmlspecialchars($r['company_name'])?></td>
|
||||
<td><?=htmlspecialchars($r['representative'])?></td>
|
||||
<td><?=htmlspecialchars($r['open_date'])?></td>
|
||||
<td><?=htmlspecialchars($r['created_at'])?></td>
|
||||
</tr>
|
||||
<?php endforeach; ?>
|
||||
</table>
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 운영 팁 (카페24 웹호스팅 한정)
|
||||
|
||||
* **서버 exec 미사용**: Tesseract, Poppler 같은 바이너리 설치 불가하므로 브라우저 OCR이 현실적.
|
||||
* **용량/속도**: PDF가 크면 1페이지만 먼저 처리 → “추가 페이지 인식” 버튼으로 확장.
|
||||
* **언어팩 캐시**: Tesseract.js가 언어 데이터(약 수~수십 MB)를 내려받습니다. CDN 캐시 이점이 큼.
|
||||
* **정확도 올리기**: PDF 렌더 스케일을 `2.0→2.5~3.0`으로, 이미지면 `<img>`를 `<canvas>`로 리샘플 후 OCR.
|
||||
* **보정 UX**: 자동 채움 후 노란 배경(샘플처럼 `.hl`) + 탭 이동 → 저장.
|
||||
* **보안**: 저장 전 **숫자 마스킹**(예: `대표자 주민번호 일부`)이 필요하면 프런트/백 모두에서 처리.
|
||||
|
||||
---
|
||||
|
||||
## 다음 단계(나중에 고도화)
|
||||
|
||||
* Python API(DeepSeek-OCR/PaddleOCR) 별도 서버 → 같은 방식으로 **/save_biz.php** 앞단에서 교체 호출.
|
||||
* 앵커 크롭 후 2차 OCR(속도/정확도↑), 주소 정규화, 체크섬 실패 시 재인식 루틴 등.
|
||||
|
||||
필요하면 **여러 페이지 PDF 지원**, **라라벨 컨트롤러 버전**, **Docker-Compose 모식도**, **Nginx 리버스 프록시 샘플**까지 이어서 드릴게요.
|
||||
280
ocr/edit.php
Normal file
280
ocr/edit.php
Normal file
@@ -0,0 +1,280 @@
|
||||
<?php
|
||||
require_once($_SERVER['DOCUMENT_ROOT'] . "/session.php");
|
||||
require_once($_SERVER['DOCUMENT_ROOT'] . "/load_header.php");
|
||||
require_once(__DIR__ . '/../lib/mydb.php');
|
||||
|
||||
// 권한 체크 (레벨 5 이하만 접근)
|
||||
if ($level > 5) {
|
||||
echo "<script>alert('접근 권한이 없습니다.'); history.back();</script>";
|
||||
exit;
|
||||
}
|
||||
|
||||
$id = isset($_GET['id']) ? intval($_GET['id']) : 0;
|
||||
|
||||
if ($id <= 0) {
|
||||
echo "<script>alert('잘못된 접근입니다.'); location.href='list.php';</script>";
|
||||
exit;
|
||||
}
|
||||
|
||||
$pdo = db_connect();
|
||||
|
||||
// 데이터 조회
|
||||
$sql = "SELECT * FROM biz_cert WHERE id = :id";
|
||||
$stmt = $pdo->prepare($sql);
|
||||
$stmt->bindValue(':id', $id, PDO::PARAM_INT);
|
||||
$stmt->execute();
|
||||
$data = $stmt->fetch(PDO::FETCH_ASSOC);
|
||||
|
||||
if (!$data) {
|
||||
echo "<script>alert('데이터를 찾을 수 없습니다.'); location.href='list.php';</script>";
|
||||
exit;
|
||||
}
|
||||
?>
|
||||
|
||||
<!DOCTYPE html>
|
||||
<html lang="ko">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>사업자등록증 정보 수정</title>
|
||||
<style>
|
||||
.edit-container {
|
||||
max-width: 800px;
|
||||
margin: 20px auto;
|
||||
padding: 20px;
|
||||
background: white;
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 2px 4px rgba(0,0,0,0.1);
|
||||
}
|
||||
.form-group {
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
.form-group label {
|
||||
display: block;
|
||||
font-weight: bold;
|
||||
margin-bottom: 8px;
|
||||
color: #333;
|
||||
}
|
||||
.form-group input,
|
||||
.form-group textarea {
|
||||
width: 100%;
|
||||
padding: 10px;
|
||||
border: 1px solid #ddd;
|
||||
border-radius: 4px;
|
||||
font-size: 14px;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
.form-group input:focus,
|
||||
.form-group textarea:focus {
|
||||
outline: none;
|
||||
border-color: #0d6efd;
|
||||
}
|
||||
.form-group textarea {
|
||||
min-height: 80px;
|
||||
resize: vertical;
|
||||
}
|
||||
.form-group small {
|
||||
display: block;
|
||||
color: #666;
|
||||
margin-top: 4px;
|
||||
font-size: 12px;
|
||||
}
|
||||
.btn {
|
||||
padding: 10px 24px;
|
||||
border: none;
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
font-size: 14px;
|
||||
font-weight: bold;
|
||||
text-decoration: none;
|
||||
display: inline-block;
|
||||
}
|
||||
.btn-primary {
|
||||
background: #0d6efd;
|
||||
color: white;
|
||||
}
|
||||
.btn-primary:hover {
|
||||
background: #0b5ed7;
|
||||
}
|
||||
.btn-secondary {
|
||||
background: #6c757d;
|
||||
color: white;
|
||||
}
|
||||
.btn-secondary:hover {
|
||||
background: #5c636a;
|
||||
}
|
||||
.button-group {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
margin-top: 30px;
|
||||
padding-top: 20px;
|
||||
border-top: 1px solid #dee2e6;
|
||||
}
|
||||
.validation-error {
|
||||
color: #dc3545;
|
||||
font-size: 12px;
|
||||
margin-top: 4px;
|
||||
display: none;
|
||||
}
|
||||
.header-section {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 20px;
|
||||
padding-bottom: 15px;
|
||||
border-bottom: 2px solid #dee2e6;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
|
||||
<?php include $_SERVER['DOCUMENT_ROOT'] . "/myheader.php"; ?>
|
||||
|
||||
<div class="edit-container">
|
||||
<div class="header-section">
|
||||
<h3><i class="bi bi-pencil"></i> 사업자등록증 정보 수정</h3>
|
||||
<div>
|
||||
<a href="list.php" class="btn btn-secondary">
|
||||
<i class="bi bi-list-ul"></i> 목록으로
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<form id="editForm" method="post" action="update.php">
|
||||
<input type="hidden" name="id" value="<?php echo htmlspecialchars($data['id']); ?>">
|
||||
|
||||
<div class="form-group">
|
||||
<label for="biz_no">
|
||||
사업자등록번호 <span style="color: #dc3545;">*</span>
|
||||
</label>
|
||||
<input type="text" id="biz_no" name="biz_no"
|
||||
value="<?php echo htmlspecialchars($data['biz_no']); ?>"
|
||||
placeholder="000-00-00000" maxlength="12" required>
|
||||
<small>형식: 000-00-00000 (10자리)</small>
|
||||
<div class="validation-error" id="biz_no_error"></div>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="company_name">
|
||||
상호명 (법인명) <span style="color: #dc3545;">*</span>
|
||||
</label>
|
||||
<input type="text" id="company_name" name="company_name"
|
||||
value="<?php echo htmlspecialchars($data['company_name']); ?>"
|
||||
placeholder="주식회사 홍길동" required>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="representative">
|
||||
대표자명 <span style="color: #dc3545;">*</span>
|
||||
</label>
|
||||
<input type="text" id="representative" name="representative"
|
||||
value="<?php echo htmlspecialchars($data['representative']); ?>"
|
||||
placeholder="홍길동" required>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="open_date">개업일자</label>
|
||||
<input type="date" id="open_date" name="open_date"
|
||||
value="<?php echo htmlspecialchars($data['open_date']); ?>">
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="address">본점 소재지</label>
|
||||
<textarea id="address" name="address"
|
||||
placeholder="서울특별시 강남구..."><?php echo htmlspecialchars($data['address']); ?></textarea>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="type">업태</label>
|
||||
<input type="text" id="type" name="type"
|
||||
value="<?php echo htmlspecialchars($data['type']); ?>"
|
||||
placeholder="제조업, 도소매업 등">
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="item">종목</label>
|
||||
<input type="text" id="item" name="item"
|
||||
value="<?php echo htmlspecialchars($data['item']); ?>"
|
||||
placeholder="건축자재, 전자제품 등">
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="issue_date">발급일자</label>
|
||||
<input type="date" id="issue_date" name="issue_date"
|
||||
value="<?php echo htmlspecialchars($data['issue_date']); ?>">
|
||||
</div>
|
||||
|
||||
<div class="button-group">
|
||||
<button type="submit" class="btn btn-primary">
|
||||
<i class="bi bi-check-circle"></i> 저장
|
||||
</button>
|
||||
<a href="list.php" class="btn btn-secondary">
|
||||
<i class="bi bi-x-circle"></i> 취소
|
||||
</a>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
// 사업자번호 자동 하이픈 추가
|
||||
document.getElementById('biz_no').addEventListener('input', function(e) {
|
||||
let value = e.target.value.replace(/[^0-9]/g, '');
|
||||
if (value.length > 10) value = value.substr(0, 10);
|
||||
|
||||
if (value.length >= 3) {
|
||||
value = value.substr(0, 3) + '-' + value.substr(3);
|
||||
}
|
||||
if (value.length >= 6) {
|
||||
value = value.substr(0, 6) + '-' + value.substr(6);
|
||||
}
|
||||
|
||||
e.target.value = value;
|
||||
});
|
||||
|
||||
// 사업자번호 검증
|
||||
function validateBizNo(bizNo) {
|
||||
const cleaned = bizNo.replace(/[^0-9]/g, '');
|
||||
if (cleaned.length !== 10) return false;
|
||||
|
||||
const checksum = [1, 3, 7, 1, 3, 7, 1, 3, 5];
|
||||
let sum = 0;
|
||||
|
||||
for (let i = 0; i < 9; i++) {
|
||||
sum += parseInt(cleaned[i]) * checksum[i];
|
||||
}
|
||||
|
||||
sum += Math.floor((parseInt(cleaned[8]) * 5) / 10);
|
||||
const lastDigit = (10 - (sum % 10)) % 10;
|
||||
|
||||
return lastDigit === parseInt(cleaned[9]);
|
||||
}
|
||||
|
||||
// 폼 제출 전 검증
|
||||
document.getElementById('editForm').addEventListener('submit', function(e) {
|
||||
const bizNo = document.getElementById('biz_no').value;
|
||||
const errorDiv = document.getElementById('biz_no_error');
|
||||
|
||||
if (!validateBizNo(bizNo)) {
|
||||
e.preventDefault();
|
||||
errorDiv.textContent = '올바른 사업자등록번호가 아닙니다. 형식과 체크섬을 확인해주세요.';
|
||||
errorDiv.style.display = 'block';
|
||||
document.getElementById('biz_no').focus();
|
||||
return false;
|
||||
}
|
||||
|
||||
errorDiv.style.display = 'none';
|
||||
return true;
|
||||
});
|
||||
|
||||
// 페이지 로드 완료 시 loader 숨기기
|
||||
window.addEventListener('load', function() {
|
||||
const loadingOverlay = document.getElementById('loadingOverlay');
|
||||
if (loadingOverlay) {
|
||||
loadingOverlay.style.display = 'none';
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
</body>
|
||||
</html>
|
||||
|
||||
1387
ocr/index.php
Normal file
1387
ocr/index.php
Normal file
File diff suppressed because it is too large
Load Diff
510
ocr/list.php
Normal file
510
ocr/list.php
Normal file
@@ -0,0 +1,510 @@
|
||||
<?php
|
||||
require_once($_SERVER['DOCUMENT_ROOT'] . "/session.php");
|
||||
require_once($_SERVER['DOCUMENT_ROOT'] . "/load_header.php");
|
||||
require_once(__DIR__ . '/../lib/mydb.php');
|
||||
|
||||
// 권한 체크 (레벨 5 이하만 접근)
|
||||
if ($level > 5) {
|
||||
echo "<script>alert('접근 권한이 없습니다.'); history.back();</script>";
|
||||
exit;
|
||||
}
|
||||
|
||||
$pdo = db_connect();
|
||||
|
||||
// 페이징 설정
|
||||
$page = isset($_GET['page']) ? max(1, intval($_GET['page'])) : 1;
|
||||
$per_page = 20;
|
||||
$offset = ($page - 1) * $per_page;
|
||||
|
||||
// 검색 기능
|
||||
$search_keyword = isset($_GET['search']) ? trim($_GET['search']) : '';
|
||||
$where_clause = '';
|
||||
$params = [];
|
||||
|
||||
if ($search_keyword) {
|
||||
$where_clause = " WHERE biz_no LIKE :search OR company_name LIKE :search OR representative LIKE :search";
|
||||
$params[':search'] = '%' . $search_keyword . '%';
|
||||
}
|
||||
|
||||
// 전체 레코드 수 조회
|
||||
$count_sql = "SELECT COUNT(*) as total FROM biz_cert" . $where_clause;
|
||||
$count_stmt = $pdo->prepare($count_sql);
|
||||
$count_stmt->execute($params);
|
||||
$total_records = $count_stmt->fetch(PDO::FETCH_ASSOC)['total'];
|
||||
$total_pages = ceil($total_records / $per_page);
|
||||
|
||||
// 데이터 조회
|
||||
$sql = "SELECT id, biz_no, company_name, representative, open_date, issue_date, created_at
|
||||
FROM biz_cert" . $where_clause . "
|
||||
ORDER BY id DESC
|
||||
LIMIT :limit OFFSET :offset";
|
||||
$stmt = $pdo->prepare($sql);
|
||||
|
||||
foreach ($params as $key => $value) {
|
||||
$stmt->bindValue($key, $value, PDO::PARAM_STR);
|
||||
}
|
||||
$stmt->bindValue(':limit', $per_page, PDO::PARAM_INT);
|
||||
$stmt->bindValue(':offset', $offset, PDO::PARAM_INT);
|
||||
$stmt->execute();
|
||||
$rows = $stmt->fetchAll(PDO::FETCH_ASSOC);
|
||||
?>
|
||||
|
||||
<!DOCTYPE html>
|
||||
<html lang="ko">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>사업자등록증 OCR 목록</title>
|
||||
<style>
|
||||
.ocr-container {
|
||||
max-width: 1400px;
|
||||
margin: 20px auto;
|
||||
padding: 20px;
|
||||
}
|
||||
.header-section {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
.search-section {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
margin-bottom: 20px;
|
||||
align-items: center;
|
||||
}
|
||||
.search-section input {
|
||||
padding: 8px 12px;
|
||||
border: 1px solid #ddd;
|
||||
border-radius: 4px;
|
||||
width: 300px;
|
||||
}
|
||||
.btn {
|
||||
padding: 8px 20px;
|
||||
border: none;
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
font-size: 14px;
|
||||
font-weight: bold;
|
||||
text-decoration: none;
|
||||
display: inline-block;
|
||||
}
|
||||
.btn-primary {
|
||||
background: #0d6efd;
|
||||
color: white;
|
||||
}
|
||||
.btn-primary:hover {
|
||||
background: #0b5ed7;
|
||||
}
|
||||
.btn-secondary {
|
||||
background: #6c757d;
|
||||
color: white;
|
||||
}
|
||||
.btn-secondary:hover {
|
||||
background: #5c636a;
|
||||
}
|
||||
.btn-success {
|
||||
background: #198754;
|
||||
color: white;
|
||||
}
|
||||
.btn-success:hover {
|
||||
background: #157347;
|
||||
}
|
||||
.data-table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
background: white;
|
||||
box-shadow: 0 2px 4px rgba(0,0,0,0.1);
|
||||
border-radius: 4px;
|
||||
overflow: hidden;
|
||||
}
|
||||
.data-table th {
|
||||
background: #f8f9fa;
|
||||
padding: 12px;
|
||||
text-align: left;
|
||||
font-weight: bold;
|
||||
border-bottom: 2px solid #dee2e6;
|
||||
}
|
||||
.data-table td {
|
||||
padding: 12px;
|
||||
border-bottom: 1px solid #dee2e6;
|
||||
}
|
||||
.data-table tr:hover {
|
||||
background: #f8f9fa;
|
||||
}
|
||||
.data-table tr:last-child td {
|
||||
border-bottom: none;
|
||||
}
|
||||
.pagination {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
gap: 5px;
|
||||
margin-top: 20px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.pagination a, .pagination span {
|
||||
padding: 8px 12px;
|
||||
border: 1px solid #ddd;
|
||||
border-radius: 4px;
|
||||
text-decoration: none;
|
||||
color: #333;
|
||||
}
|
||||
.pagination a:hover {
|
||||
background: #f8f9fa;
|
||||
}
|
||||
.pagination .active {
|
||||
background: #0d6efd;
|
||||
color: white;
|
||||
border-color: #0d6efd;
|
||||
}
|
||||
.stats {
|
||||
color: #6c757d;
|
||||
font-size: 14px;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
.view-link {
|
||||
color: #0d6efd;
|
||||
text-decoration: none;
|
||||
cursor: pointer;
|
||||
}
|
||||
.view-link:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
.modal {
|
||||
display: none;
|
||||
position: fixed;
|
||||
z-index: 1000;
|
||||
left: 0;
|
||||
top: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
overflow: auto;
|
||||
background-color: rgba(0,0,0,0.4);
|
||||
}
|
||||
.modal-content {
|
||||
background-color: #fefefe;
|
||||
margin: 5% auto;
|
||||
padding: 20px;
|
||||
border: 1px solid #888;
|
||||
border-radius: 8px;
|
||||
width: 80%;
|
||||
max-width: 800px;
|
||||
}
|
||||
.close {
|
||||
color: #aaa;
|
||||
float: right;
|
||||
font-size: 28px;
|
||||
font-weight: bold;
|
||||
cursor: pointer;
|
||||
}
|
||||
.close:hover {
|
||||
color: #000;
|
||||
}
|
||||
.detail-group {
|
||||
margin-bottom: 15px;
|
||||
padding-bottom: 15px;
|
||||
border-bottom: 1px solid #eee;
|
||||
}
|
||||
.detail-group:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
.detail-label {
|
||||
font-weight: bold;
|
||||
color: #333;
|
||||
display: inline-block;
|
||||
width: 150px;
|
||||
}
|
||||
.detail-value {
|
||||
color: #666;
|
||||
}
|
||||
.raw-text-box {
|
||||
background: #f8f9fa;
|
||||
padding: 10px;
|
||||
border-radius: 4px;
|
||||
max-height: 200px;
|
||||
overflow-y: auto;
|
||||
font-family: 'Courier New', monospace;
|
||||
font-size: 12px;
|
||||
white-space: pre-wrap;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
|
||||
<?php include $_SERVER['DOCUMENT_ROOT'] . "/myheader.php"; ?>
|
||||
|
||||
<div class="ocr-container">
|
||||
<div class="header-section">
|
||||
<div>
|
||||
<h3><i class="bi bi-list-ul"></i> 사업자등록증 OCR 목록</h3>
|
||||
<p class="stats">
|
||||
총 <?php echo number_format($total_records); ?>건
|
||||
(<?php echo $page; ?> / <?php echo $total_pages; ?> 페이지)
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<a href="index.php" class="btn btn-success">
|
||||
<i class="bi bi-plus-circle"></i> 새로 등록
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="search-section">
|
||||
<form method="get" action="" style="display: flex; gap: 10px; align-items: center;">
|
||||
<input type="text" name="search" placeholder="사업자번호, 상호명, 대표자명 검색..."
|
||||
value="<?php echo htmlspecialchars($search_keyword); ?>">
|
||||
<button type="submit" class="btn btn-primary">
|
||||
<i class="bi bi-search"></i> 검색
|
||||
</button>
|
||||
<?php if ($search_keyword): ?>
|
||||
<a href="list.php" class="btn btn-secondary">
|
||||
<i class="bi bi-x-circle"></i> 초기화
|
||||
</a>
|
||||
<?php endif; ?>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<?php if (empty($rows)): ?>
|
||||
<div style="text-align: center; padding: 40px; background: white; border-radius: 4px;">
|
||||
<i class="bi bi-inbox" style="font-size: 48px; color: #ccc;"></i>
|
||||
<p style="color: #999; margin-top: 10px;">
|
||||
<?php echo $search_keyword ? '검색 결과가 없습니다.' : '등록된 데이터가 없습니다.'; ?>
|
||||
</p>
|
||||
</div>
|
||||
<?php else: ?>
|
||||
<table class="data-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th style="width: 60px;">ID</th>
|
||||
<th style="width: 150px;">사업자번호</th>
|
||||
<th>상호명</th>
|
||||
<th style="width: 120px;">대표자</th>
|
||||
<th style="width: 120px;">개업일자</th>
|
||||
<th style="width: 120px;">발급일자</th>
|
||||
<th style="width: 150px;">등록일시</th>
|
||||
<th style="width: 150px;">관리</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<?php foreach ($rows as $row): ?>
|
||||
<tr>
|
||||
<td><?php echo htmlspecialchars($row['id']); ?></td>
|
||||
<td><?php echo htmlspecialchars($row['biz_no']); ?></td>
|
||||
<td><?php echo htmlspecialchars($row['company_name']); ?></td>
|
||||
<td><?php echo htmlspecialchars($row['representative']); ?></td>
|
||||
<td><?php echo $row['open_date'] ? htmlspecialchars($row['open_date']) : '-'; ?></td>
|
||||
<td><?php echo $row['issue_date'] ? htmlspecialchars($row['issue_date']) : '-'; ?></td>
|
||||
<td><?php echo htmlspecialchars($row['created_at']); ?></td>
|
||||
<td>
|
||||
<a href="javascript:void(0)" class="view-link" onclick="viewDetail(<?php echo $row['id']; ?>)" title="상세보기">
|
||||
<i class="bi bi-eye"></i>
|
||||
</a>
|
||||
<a href="edit.php?id=<?php echo $row['id']; ?>" class="view-link" style="color: #198754; margin-left: 8px;" title="수정">
|
||||
<i class="bi bi-pencil"></i>
|
||||
</a>
|
||||
<a href="javascript:void(0)" class="view-link" style="color: #dc3545; margin-left: 8px;" onclick="confirmDelete(<?php echo $row['id']; ?>, '<?php echo htmlspecialchars($row['company_name'], ENT_QUOTES); ?>')" title="삭제">
|
||||
<i class="bi bi-trash"></i>
|
||||
</a>
|
||||
</td>
|
||||
</tr>
|
||||
<?php endforeach; ?>
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
<?php if ($total_pages > 1): ?>
|
||||
<div class="pagination">
|
||||
<?php if ($page > 1): ?>
|
||||
<a href="?page=1<?php echo $search_keyword ? '&search=' . urlencode($search_keyword) : ''; ?>">처음</a>
|
||||
<a href="?page=<?php echo $page - 1; ?><?php echo $search_keyword ? '&search=' . urlencode($search_keyword) : ''; ?>">이전</a>
|
||||
<?php endif; ?>
|
||||
|
||||
<?php
|
||||
$start_page = max(1, $page - 5);
|
||||
$end_page = min($total_pages, $page + 5);
|
||||
|
||||
for ($i = $start_page; $i <= $end_page; $i++):
|
||||
if ($i == $page):
|
||||
?>
|
||||
<span class="active"><?php echo $i; ?></span>
|
||||
<?php else: ?>
|
||||
<a href="?page=<?php echo $i; ?><?php echo $search_keyword ? '&search=' . urlencode($search_keyword) : ''; ?>">
|
||||
<?php echo $i; ?>
|
||||
</a>
|
||||
<?php
|
||||
endif;
|
||||
endfor;
|
||||
?>
|
||||
|
||||
<?php if ($page < $total_pages): ?>
|
||||
<a href="?page=<?php echo $page + 1; ?><?php echo $search_keyword ? '&search=' . urlencode($search_keyword) : ''; ?>">다음</a>
|
||||
<a href="?page=<?php echo $total_pages; ?><?php echo $search_keyword ? '&search=' . urlencode($search_keyword) : ''; ?>">마지막</a>
|
||||
<?php endif; ?>
|
||||
</div>
|
||||
<?php endif; ?>
|
||||
<?php endif; ?>
|
||||
</div>
|
||||
|
||||
<!-- 상세보기 모달 -->
|
||||
<div id="detailModal" class="modal">
|
||||
<div class="modal-content">
|
||||
<span class="close" onclick="closeModal()">×</span>
|
||||
<h4 style="margin-top: 0;"><i class="bi bi-file-earmark-text"></i> 사업자등록증 상세정보</h4>
|
||||
<div id="detailContent">
|
||||
<p style="text-align: center; color: #999;">로딩중...</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
function viewDetail(id) {
|
||||
const modal = document.getElementById('detailModal');
|
||||
const content = document.getElementById('detailContent');
|
||||
|
||||
modal.style.display = 'block';
|
||||
content.innerHTML = '<p style="text-align: center; color: #999;">로딩중...</p>';
|
||||
|
||||
fetch('view_detail.php?id=' + id)
|
||||
.then(response => response.json())
|
||||
.then(data => {
|
||||
if (data.ok) {
|
||||
const d = data.data;
|
||||
content.innerHTML = `
|
||||
<div class="detail-group">
|
||||
<span class="detail-label">ID:</span>
|
||||
<span class="detail-value">${escapeHtml(d.id)}</span>
|
||||
</div>
|
||||
<div class="detail-group">
|
||||
<span class="detail-label">사업자등록번호:</span>
|
||||
<span class="detail-value">${escapeHtml(d.biz_no)}</span>
|
||||
</div>
|
||||
<div class="detail-group">
|
||||
<span class="detail-label">상호명:</span>
|
||||
<span class="detail-value">${escapeHtml(d.company_name)}</span>
|
||||
</div>
|
||||
<div class="detail-group">
|
||||
<span class="detail-label">대표자명:</span>
|
||||
<span class="detail-value">${escapeHtml(d.representative)}</span>
|
||||
</div>
|
||||
<div class="detail-group">
|
||||
<span class="detail-label">개업일자:</span>
|
||||
<span class="detail-value">${d.open_date ? escapeHtml(d.open_date) : '-'}</span>
|
||||
</div>
|
||||
<div class="detail-group">
|
||||
<span class="detail-label">본점 소재지:</span>
|
||||
<span class="detail-value">${d.address ? escapeHtml(d.address) : '-'}</span>
|
||||
</div>
|
||||
<div class="detail-group">
|
||||
<span class="detail-label">업태:</span>
|
||||
<span class="detail-value">${d.type ? escapeHtml(d.type) : '-'}</span>
|
||||
</div>
|
||||
<div class="detail-group">
|
||||
<span class="detail-label">종목:</span>
|
||||
<span class="detail-value">${d.item ? escapeHtml(d.item) : '-'}</span>
|
||||
</div>
|
||||
<div class="detail-group">
|
||||
<span class="detail-label">발급일자:</span>
|
||||
<span class="detail-value">${d.issue_date ? escapeHtml(d.issue_date) : '-'}</span>
|
||||
</div>
|
||||
<div class="detail-group">
|
||||
<span class="detail-label">등록일시:</span>
|
||||
<span class="detail-value">${escapeHtml(d.created_at)}</span>
|
||||
</div>
|
||||
${d.raw_text ? `
|
||||
<div class="detail-group">
|
||||
<span class="detail-label">OCR 원문:</span><br>
|
||||
<div class="raw-text-box">${escapeHtml(d.raw_text)}</div>
|
||||
</div>
|
||||
` : ''}
|
||||
<div style="margin-top: 20px; text-align: right; display: flex; gap: 10px; justify-content: flex-end;">
|
||||
<a href="edit.php?id=${d.id}" class="btn btn-success">
|
||||
<i class="bi bi-pencil"></i> 수정
|
||||
</a>
|
||||
<button type="button" class="btn" style="background: #dc3545; color: white;" onclick="confirmDeleteFromModal(${d.id}, '${escapeHtml(d.company_name).replace(/'/g, "\\'")}')">
|
||||
<i class="bi bi-trash"></i> 삭제
|
||||
</button>
|
||||
</div>
|
||||
`;
|
||||
} else {
|
||||
content.innerHTML = '<p style="color: red;">오류: ' + escapeHtml(data.error) + '</p>';
|
||||
}
|
||||
})
|
||||
.catch(error => {
|
||||
content.innerHTML = '<p style="color: red;">데이터 로드 실패</p>';
|
||||
console.error('Error:', error);
|
||||
});
|
||||
}
|
||||
|
||||
function closeModal() {
|
||||
document.getElementById('detailModal').style.display = 'none';
|
||||
}
|
||||
|
||||
function escapeHtml(text) {
|
||||
const map = {
|
||||
'&': '&',
|
||||
'<': '<',
|
||||
'>': '>',
|
||||
'"': '"',
|
||||
"'": '''
|
||||
};
|
||||
return (text || '').toString().replace(/[&<>"']/g, m => map[m]);
|
||||
}
|
||||
|
||||
// 삭제 확인 (목록에서)
|
||||
function confirmDelete(id, companyName) {
|
||||
if (confirm(`정말로 "${companyName}" 데이터를 삭제하시겠습니까?\n\n이 작업은 되돌릴 수 없습니다.`)) {
|
||||
deleteRecord(id);
|
||||
}
|
||||
}
|
||||
|
||||
// 삭제 확인 (모달에서)
|
||||
function confirmDeleteFromModal(id, companyName) {
|
||||
if (confirm(`정말로 "${companyName}" 데이터를 삭제하시겠습니까?\n\n이 작업은 되돌릴 수 없습니다.`)) {
|
||||
deleteRecord(id);
|
||||
}
|
||||
}
|
||||
|
||||
// 삭제 실행
|
||||
function deleteRecord(id) {
|
||||
fetch('delete.php', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({ id: id })
|
||||
})
|
||||
.then(response => response.json())
|
||||
.then(data => {
|
||||
if (data.ok) {
|
||||
alert('삭제되었습니다.');
|
||||
closeModal();
|
||||
location.reload();
|
||||
} else {
|
||||
alert('삭제 실패: ' + (data.error || '알 수 없는 오류'));
|
||||
}
|
||||
})
|
||||
.catch(error => {
|
||||
alert('삭제 중 오류가 발생했습니다.');
|
||||
console.error('Error:', error);
|
||||
});
|
||||
}
|
||||
|
||||
// 모달 외부 클릭시 닫기
|
||||
window.onclick = function(event) {
|
||||
const modal = document.getElementById('detailModal');
|
||||
if (event.target == modal) {
|
||||
closeModal();
|
||||
}
|
||||
}
|
||||
|
||||
// 페이지 로드 완료 시 loader 숨기기
|
||||
window.addEventListener('load', function() {
|
||||
const loadingOverlay = document.getElementById('loadingOverlay');
|
||||
if (loadingOverlay) {
|
||||
loadingOverlay.style.display = 'none';
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
</body>
|
||||
</html>
|
||||
|
||||
114
ocr/save_biz.php
Normal file
114
ocr/save_biz.php
Normal file
@@ -0,0 +1,114 @@
|
||||
<?php
|
||||
// save_biz.php - 사업자등록증 OCR 데이터 저장 API
|
||||
header('Content-Type: application/json; charset=utf-8');
|
||||
require_once(__DIR__ . '/../lib/mydb.php');
|
||||
require_once($_SERVER['DOCUMENT_ROOT'] . "/session.php");
|
||||
|
||||
// 권한 체크 (레벨 5 이하만 접근)
|
||||
if ($level > 5) {
|
||||
http_response_code(403);
|
||||
echo json_encode(['ok' => false, 'error' => '접근 권한이 없습니다.']);
|
||||
exit;
|
||||
}
|
||||
|
||||
// 사업자번호 정규화 (000-00-00000 형식)
|
||||
function normalize_biz($v) {
|
||||
$d = preg_replace('/\D/', '', $v ?? '');
|
||||
return (strlen($d) === 10) ? substr($d, 0, 3) . '-' . substr($d, 3, 2) . '-' . substr($d, 5) : ($v ?? '');
|
||||
}
|
||||
|
||||
// 사업자번호 검증 (체크섬)
|
||||
function valid_biz_no($s) {
|
||||
$d = preg_replace('/\D/', '', $s ?? '');
|
||||
if (strlen($d) != 10) return false;
|
||||
|
||||
$w = [1, 3, 7, 1, 3, 7, 1, 3, 5];
|
||||
$sum = 0;
|
||||
for ($i = 0; $i < 9; $i++) {
|
||||
$sum += intval($d[$i]) * $w[$i];
|
||||
}
|
||||
$sum += intdiv(intval($d[8]) * 5, 10);
|
||||
$check = (10 - ($sum % 10)) % 10;
|
||||
return $check == intval($d[9]);
|
||||
}
|
||||
|
||||
// 날짜 정규화
|
||||
function to_date($s) {
|
||||
if (!$s) return null;
|
||||
$d = preg_replace('/[년월.\-\/\s]+/', '-', $s);
|
||||
$d = preg_replace('/-+/', '-', $d);
|
||||
$d = rtrim($d, '-');
|
||||
|
||||
if (preg_match('/^(\d{4})-(\d{1,2})-(\d{1,2})$/', $d, $m)) {
|
||||
return sprintf('%04d-%02d-%02d', $m[1], $m[2], $m[3]);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
// JSON 입력 받기
|
||||
$input = json_decode(file_get_contents('php://input'), true);
|
||||
if (!$input) {
|
||||
http_response_code(400);
|
||||
echo json_encode(['ok' => false, 'error' => 'Invalid JSON']);
|
||||
exit;
|
||||
}
|
||||
|
||||
// 데이터 추출 및 검증
|
||||
$biz_no = normalize_biz($input['biz_no'] ?? '');
|
||||
$company_name = trim($input['company_name'] ?? '');
|
||||
$representative = trim($input['representative'] ?? '');
|
||||
$open_date = to_date($input['open_date'] ?? '');
|
||||
$address = trim($input['address'] ?? '');
|
||||
$type = trim($input['type'] ?? '');
|
||||
$item = trim($input['item'] ?? '');
|
||||
$issue_date = to_date($input['issue_date'] ?? '');
|
||||
$raw_text = $input['raw_text'] ?? '';
|
||||
|
||||
// 필수값 체크
|
||||
if (!$biz_no || !$company_name || !$representative) {
|
||||
http_response_code(400);
|
||||
echo json_encode(['ok' => false, 'error' => '필수값 누락 (사업자번호/상호명/대표자명)']);
|
||||
exit;
|
||||
}
|
||||
|
||||
// 사업자번호 유효성 체크
|
||||
$is_valid_biz = valid_biz_no($biz_no);
|
||||
|
||||
try {
|
||||
$pdo = db_connect();
|
||||
|
||||
$stmt = $pdo->prepare("
|
||||
INSERT INTO biz_cert
|
||||
(biz_no, company_name, representative, open_date, address, type, item, issue_date, raw_text)
|
||||
VALUES (:biz_no, :company_name, :representative, :open_date, :address, :type, :item, :issue_date, :raw_text)
|
||||
");
|
||||
|
||||
$stmt->execute([
|
||||
':biz_no' => $biz_no,
|
||||
':company_name' => $company_name,
|
||||
':representative' => $representative,
|
||||
':open_date' => $open_date,
|
||||
':address' => $address,
|
||||
':type' => $type,
|
||||
':item' => $item,
|
||||
':issue_date' => $issue_date,
|
||||
':raw_text' => $raw_text,
|
||||
]);
|
||||
|
||||
$id = $pdo->lastInsertId();
|
||||
|
||||
echo json_encode([
|
||||
'ok' => true,
|
||||
'id' => $id,
|
||||
'valid_biz_no' => $is_valid_biz,
|
||||
'message' => $is_valid_biz ? '저장되었습니다.' : '저장되었으나 사업자번호 검증 실패 (수동 확인 필요)'
|
||||
]);
|
||||
|
||||
} catch (PDOException $e) {
|
||||
http_response_code(500);
|
||||
echo json_encode(['ok' => false, 'error' => '데이터베이스 오류: ' . $e->getMessage()]);
|
||||
} catch (Exception $e) {
|
||||
http_response_code(500);
|
||||
echo json_encode(['ok' => false, 'error' => $e->getMessage()]);
|
||||
}
|
||||
|
||||
29
ocr/test_api.php
Normal file
29
ocr/test_api.php
Normal file
@@ -0,0 +1,29 @@
|
||||
<?php
|
||||
// 간단한 API 연결 테스트
|
||||
require_once($_SERVER['DOCUMENT_ROOT'] . "/session.php");
|
||||
|
||||
// 권한 체크
|
||||
if ($level > 5) {
|
||||
die(json_encode(['ok' => false, 'error' => '접근 권한이 없습니다.']));
|
||||
}
|
||||
|
||||
// API 키 읽기
|
||||
$apiKeyFile = $_SERVER['DOCUMENT_ROOT'] . '/apikey/claude_api.txt';
|
||||
|
||||
$response = [
|
||||
'file_exists' => file_exists($apiKeyFile),
|
||||
'file_path' => $apiKeyFile,
|
||||
'document_root' => $_SERVER['DOCUMENT_ROOT']
|
||||
];
|
||||
|
||||
if (file_exists($apiKeyFile)) {
|
||||
$apiKey = trim(file_get_contents($apiKeyFile));
|
||||
$response['key_length'] = strlen($apiKey);
|
||||
$response['key_prefix'] = substr($apiKey, 0, 20) . '...';
|
||||
$response['key_valid_format'] = (strpos($apiKey, 'sk-ant-api') === 0);
|
||||
} else {
|
||||
$response['error'] = 'API 키 파일을 찾을 수 없습니다.';
|
||||
}
|
||||
|
||||
header('Content-Type: application/json');
|
||||
echo json_encode($response, JSON_PRETTY_PRINT);
|
||||
61
ocr/test_models.php
Normal file
61
ocr/test_models.php
Normal file
@@ -0,0 +1,61 @@
|
||||
<?php
|
||||
// Claude API 모델 테스트
|
||||
require_once($_SERVER['DOCUMENT_ROOT'] . "/session.php");
|
||||
|
||||
if ($level > 5) {
|
||||
die(json_encode(['error' => '접근 권한이 없습니다.']));
|
||||
}
|
||||
|
||||
// API 키 읽기
|
||||
$apiKeyFile = $_SERVER['DOCUMENT_ROOT'] . '/apikey/claude_api.txt';
|
||||
$apiKey = trim(file_get_contents($apiKeyFile));
|
||||
|
||||
// 테스트할 모델들
|
||||
$models = [
|
||||
'claude-3-5-sonnet-20241022',
|
||||
'claude-3-5-sonnet-20240620',
|
||||
'claude-3-opus-20240229',
|
||||
'claude-3-sonnet-20240229',
|
||||
'claude-3-haiku-20240307'
|
||||
];
|
||||
|
||||
$results = [];
|
||||
|
||||
foreach ($models as $model) {
|
||||
$requestBody = [
|
||||
'model' => $model,
|
||||
'max_tokens' => 100,
|
||||
'messages' => [
|
||||
[
|
||||
'role' => 'user',
|
||||
'content' => 'Hello, just testing. Reply with OK.'
|
||||
]
|
||||
]
|
||||
];
|
||||
|
||||
$ch = curl_init('https://api.anthropic.com/v1/messages');
|
||||
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
|
||||
curl_setopt($ch, CURLOPT_POST, true);
|
||||
curl_setopt($ch, CURLOPT_HTTPHEADER, [
|
||||
'Content-Type: application/json',
|
||||
'x-api-key: ' . $apiKey,
|
||||
'anthropic-version: 2023-06-01'
|
||||
]);
|
||||
curl_setopt($ch, CURLOPT_POSTFIELDS, json_encode($requestBody));
|
||||
|
||||
$response = curl_exec($ch);
|
||||
$httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE);
|
||||
curl_close($ch);
|
||||
|
||||
$responseData = json_decode($response, true);
|
||||
|
||||
$results[$model] = [
|
||||
'status' => $httpCode,
|
||||
'success' => ($httpCode === 200),
|
||||
'error' => isset($responseData['error']) ? $responseData['error']['message'] : null,
|
||||
'response' => $httpCode === 200 ? 'OK' : null
|
||||
];
|
||||
}
|
||||
|
||||
header('Content-Type: application/json');
|
||||
echo json_encode($results, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE);
|
||||
122
ocr/update.php
Normal file
122
ocr/update.php
Normal file
@@ -0,0 +1,122 @@
|
||||
<?php
|
||||
require_once($_SERVER['DOCUMENT_ROOT'] . "/session.php");
|
||||
require_once(__DIR__ . '/../lib/mydb.php');
|
||||
|
||||
// 권한 체크 (레벨 5 이하만 접근)
|
||||
if ($level > 5) {
|
||||
echo "<script>alert('접근 권한이 없습니다.'); history.back();</script>";
|
||||
exit;
|
||||
}
|
||||
|
||||
if ($_SERVER['REQUEST_METHOD'] !== 'POST') {
|
||||
echo "<script>alert('잘못된 접근입니다.'); location.href='list.php';</script>";
|
||||
exit;
|
||||
}
|
||||
|
||||
$id = isset($_POST['id']) ? intval($_POST['id']) : 0;
|
||||
$biz_no = isset($_POST['biz_no']) ? trim($_POST['biz_no']) : '';
|
||||
$company_name = isset($_POST['company_name']) ? trim($_POST['company_name']) : '';
|
||||
$representative = isset($_POST['representative']) ? trim($_POST['representative']) : '';
|
||||
$open_date = isset($_POST['open_date']) ? trim($_POST['open_date']) : null;
|
||||
$address = isset($_POST['address']) ? trim($_POST['address']) : null;
|
||||
$type = isset($_POST['type']) ? trim($_POST['type']) : null;
|
||||
$item = isset($_POST['item']) ? trim($_POST['item']) : null;
|
||||
$issue_date = isset($_POST['issue_date']) ? trim($_POST['issue_date']) : null;
|
||||
|
||||
// 필수 필드 검증
|
||||
if ($id <= 0) {
|
||||
echo "<script>alert('잘못된 ID입니다.'); history.back();</script>";
|
||||
exit;
|
||||
}
|
||||
|
||||
if (empty($biz_no) || empty($company_name) || empty($representative)) {
|
||||
echo "<script>alert('필수 항목을 모두 입력해주세요.'); history.back();</script>";
|
||||
exit;
|
||||
}
|
||||
|
||||
// 사업자번호 정규화 (하이픈 제거)
|
||||
$biz_no_clean = preg_replace('/[^0-9]/', '', $biz_no);
|
||||
if (strlen($biz_no_clean) !== 10) {
|
||||
echo "<script>alert('사업자번호는 10자리여야 합니다.'); history.back();</script>";
|
||||
exit;
|
||||
}
|
||||
|
||||
// 사업자번호 체크섬 검증
|
||||
function validateBusinessNumber($num) {
|
||||
if (strlen($num) !== 10) return false;
|
||||
|
||||
$checksum = [1, 3, 7, 1, 3, 7, 1, 3, 5];
|
||||
$sum = 0;
|
||||
|
||||
for ($i = 0; $i < 9; $i++) {
|
||||
$sum += intval($num[$i]) * $checksum[$i];
|
||||
}
|
||||
|
||||
$sum += floor((intval($num[8]) * 5) / 10);
|
||||
$lastDigit = (10 - ($sum % 10)) % 10;
|
||||
|
||||
return $lastDigit === intval($num[9]);
|
||||
}
|
||||
|
||||
if (!validateBusinessNumber($biz_no_clean)) {
|
||||
echo "<script>alert('올바른 사업자등록번호가 아닙니다.'); history.back();</script>";
|
||||
exit;
|
||||
}
|
||||
|
||||
// 하이픈 추가된 형식으로 저장
|
||||
$biz_no_formatted = substr($biz_no_clean, 0, 3) . '-' . substr($biz_no_clean, 3, 2) . '-' . substr($biz_no_clean, 5);
|
||||
|
||||
// 날짜 형식 정규화
|
||||
$open_date = !empty($open_date) ? $open_date : null;
|
||||
$issue_date = !empty($issue_date) ? $issue_date : null;
|
||||
|
||||
// 빈 문자열을 NULL로 변환
|
||||
$address = !empty($address) ? $address : null;
|
||||
$type = !empty($type) ? $type : null;
|
||||
$item = !empty($item) ? $item : null;
|
||||
|
||||
try {
|
||||
$pdo = db_connect();
|
||||
|
||||
// 데이터 존재 확인
|
||||
$check_sql = "SELECT id FROM biz_cert WHERE id = :id";
|
||||
$check_stmt = $pdo->prepare($check_sql);
|
||||
$check_stmt->bindValue(':id', $id, PDO::PARAM_INT);
|
||||
$check_stmt->execute();
|
||||
|
||||
if (!$check_stmt->fetch()) {
|
||||
echo "<script>alert('해당 데이터를 찾을 수 없습니다.'); location.href='list.php';</script>";
|
||||
exit;
|
||||
}
|
||||
|
||||
// 업데이트
|
||||
$sql = "UPDATE biz_cert SET
|
||||
biz_no = :biz_no,
|
||||
company_name = :company_name,
|
||||
representative = :representative,
|
||||
open_date = :open_date,
|
||||
address = :address,
|
||||
type = :type,
|
||||
item = :item,
|
||||
issue_date = :issue_date
|
||||
WHERE id = :id";
|
||||
|
||||
$stmt = $pdo->prepare($sql);
|
||||
$stmt->bindValue(':biz_no', $biz_no_formatted, PDO::PARAM_STR);
|
||||
$stmt->bindValue(':company_name', $company_name, PDO::PARAM_STR);
|
||||
$stmt->bindValue(':representative', $representative, PDO::PARAM_STR);
|
||||
$stmt->bindValue(':open_date', $open_date, $open_date ? PDO::PARAM_STR : PDO::PARAM_NULL);
|
||||
$stmt->bindValue(':address', $address, $address ? PDO::PARAM_STR : PDO::PARAM_NULL);
|
||||
$stmt->bindValue(':type', $type, $type ? PDO::PARAM_STR : PDO::PARAM_NULL);
|
||||
$stmt->bindValue(':item', $item, $item ? PDO::PARAM_STR : PDO::PARAM_NULL);
|
||||
$stmt->bindValue(':issue_date', $issue_date, $issue_date ? PDO::PARAM_STR : PDO::PARAM_NULL);
|
||||
$stmt->bindValue(':id', $id, PDO::PARAM_INT);
|
||||
|
||||
$stmt->execute();
|
||||
|
||||
echo "<script>alert('수정되었습니다.'); location.href='list.php';</script>";
|
||||
|
||||
} catch (Exception $e) {
|
||||
echo "<script>alert('수정 중 오류가 발생했습니다: " . addslashes($e->getMessage()) . "'); history.back();</script>";
|
||||
}
|
||||
|
||||
54
ocr/view_detail.php
Normal file
54
ocr/view_detail.php
Normal file
@@ -0,0 +1,54 @@
|
||||
<?php
|
||||
// view_detail.php - 사업자등록증 상세정보 조회 API
|
||||
header('Content-Type: application/json; charset=utf-8');
|
||||
require_once(__DIR__ . '/../lib/mydb.php');
|
||||
require_once($_SERVER['DOCUMENT_ROOT'] . "/session.php");
|
||||
|
||||
// 권한 체크 (레벨 5 이하만 접근)
|
||||
if ($level > 5) {
|
||||
http_response_code(403);
|
||||
echo json_encode(['ok' => false, 'error' => '접근 권한이 없습니다.']);
|
||||
exit;
|
||||
}
|
||||
|
||||
$id = isset($_GET['id']) ? intval($_GET['id']) : 0;
|
||||
|
||||
if ($id <= 0) {
|
||||
http_response_code(400);
|
||||
echo json_encode(['ok' => false, 'error' => '유효하지 않은 ID입니다.']);
|
||||
exit;
|
||||
}
|
||||
|
||||
try {
|
||||
$pdo = db_connect();
|
||||
|
||||
$stmt = $pdo->prepare("
|
||||
SELECT id, biz_no, company_name, representative, open_date, address,
|
||||
type, item, issue_date, raw_text, created_at
|
||||
FROM biz_cert
|
||||
WHERE id = :id
|
||||
LIMIT 1
|
||||
");
|
||||
|
||||
$stmt->execute([':id' => $id]);
|
||||
$row = $stmt->fetch(PDO::FETCH_ASSOC);
|
||||
|
||||
if (!$row) {
|
||||
http_response_code(404);
|
||||
echo json_encode(['ok' => false, 'error' => '데이터를 찾을 수 없습니다.']);
|
||||
exit;
|
||||
}
|
||||
|
||||
echo json_encode([
|
||||
'ok' => true,
|
||||
'data' => $row
|
||||
]);
|
||||
|
||||
} catch (PDOException $e) {
|
||||
http_response_code(500);
|
||||
echo json_encode(['ok' => false, 'error' => '데이터베이스 오류: ' . $e->getMessage()]);
|
||||
} catch (Exception $e) {
|
||||
http_response_code(500);
|
||||
echo json_encode(['ok' => false, 'error' => $e->getMessage()]);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user