Compare commits
9 Commits
ba11cb9fdf
...
8190601340
| Author | SHA1 | Date | |
|---|---|---|---|
| 8190601340 | |||
| 952ad773f1 | |||
| a6c14a6b75 | |||
| b4de6ff791 | |||
| 4581ebb7c5 | |||
| 3889e2354f | |||
| fdc369c139 | |||
| 7aad05acb9 | |||
| c2dc42c565 |
1
.gitignore
vendored
1
.gitignore
vendored
@ -85,3 +85,4 @@ docker-compose.override.yml
|
|||||||
# Temp files
|
# Temp files
|
||||||
tmp/
|
tmp/
|
||||||
*.tmp
|
*.tmp
|
||||||
|
.claude/
|
||||||
|
|||||||
441
CLAUDECODE.md
Normal file
441
CLAUDECODE.md
Normal file
@ -0,0 +1,441 @@
|
|||||||
|
# 청춘약국 마일리지 QR 시스템
|
||||||
|
|
||||||
|
## 프로젝트 개요
|
||||||
|
약국 POS 시스템과 연동하여 고객이 QR 코드를 스캔하면 자동으로 마일리지가 적립되는 시스템
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🌐 접속 URL (외부 접속 가능)
|
||||||
|
|
||||||
|
### 실시간 서버 (리버스 프록시)
|
||||||
|
- **메인 페이지**: https://mile.0bin.in/
|
||||||
|
- **간편 적립**: https://mile.0bin.in/claim?t={토큰}
|
||||||
|
- **마이페이지**: https://mile.0bin.in/my-page
|
||||||
|
- **관리자 페이지**: https://mile.0bin.in/admin
|
||||||
|
|
||||||
|
### 로컬 서버
|
||||||
|
- **로컬 주소**: http://localhost:7001/
|
||||||
|
- **서버 포트**: 7001
|
||||||
|
- **실행 명령**: `cd backend && python app.py`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 📂 프로젝트 구조
|
||||||
|
|
||||||
|
```
|
||||||
|
pharmacy-pos-qr-system/
|
||||||
|
├── backend/
|
||||||
|
│ ├── app.py # Flask 웹 서버 (포트 7001)
|
||||||
|
│ ├── db/
|
||||||
|
│ │ ├── dbsetup.py # DB 연결 관리자 (MSSQL + SQLite)
|
||||||
|
│ │ ├── mileage_schema.sql # SQLite 스키마
|
||||||
|
│ │ └── mileage.db # SQLite 데이터베이스 파일
|
||||||
|
│ ├── utils/
|
||||||
|
│ │ ├── qr_token_generator.py # QR 토큰 생성 (SHA256 해시)
|
||||||
|
│ │ └── qr_label_printer.py # QR 라벨 인쇄 (Brother QL-810W)
|
||||||
|
│ ├── gui/
|
||||||
|
│ │ └── pos_sales_gui.py # POS 판매 GUI (PyQt5)
|
||||||
|
│ ├── templates/ # Flask HTML 템플릿
|
||||||
|
│ │ ├── claim_form.html # 간편 적립 페이지
|
||||||
|
│ │ ├── my_page.html # 마이페이지
|
||||||
|
│ │ ├── my_page_login.html # 마이페이지 로그인
|
||||||
|
│ │ ├── admin.html # 관리자 대시보드
|
||||||
|
│ │ └── error.html # 에러 페이지
|
||||||
|
│ └── test_integration.py # 통합 테스트 스크립트
|
||||||
|
└── CLAUDECODE.md # 이 문서
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🚀 핵심 기능
|
||||||
|
|
||||||
|
### Phase 2: QR 라벨 인쇄 ✅
|
||||||
|
1. **토큰 생성**: SHA256 해시 기반, 30일 유효기간
|
||||||
|
2. **QR 코드**: 200x200px, 높은 오류 복원력 (ERROR_CORRECT_H)
|
||||||
|
3. **URL 최적화**: 약 68자 (빠른 스캔)
|
||||||
|
- 예시: `https://pharmacy.example.com/claim?t=TEST20260123145834:795d07519294`
|
||||||
|
4. **Brother QL-810W 프린터**: 29mm 가로형 라벨 (800x306px)
|
||||||
|
5. **POS GUI 통합**: QR 생성 버튼, 미리보기 모드
|
||||||
|
|
||||||
|
### Phase 3: 간편 적립 웹앱 ✅
|
||||||
|
1. **간편 가입/적립**: 전화번호 + 이름만 입력
|
||||||
|
2. **자동 회원 생성**: 신규 사용자 자동 등록
|
||||||
|
3. **마일리지 원장**: 모든 적립 내역 추적
|
||||||
|
4. **마이페이지**: 전화번호로 포인트 조회
|
||||||
|
5. **관리자 페이지**: 전체 사용자/적립 현황 조회
|
||||||
|
6. **거래 세부 조회**: MSSQL 연동으로 판매 상품 상세 확인 (모달 팝업)
|
||||||
|
7. **모던 UI**: Noto Sans KR 폰트, 반응형 디자인
|
||||||
|
8. **POS GUI 통합**: SQLite 적립 사용자 정보 표시 (이름, 전화번호)
|
||||||
|
9. **실시간 동기화**: 30초마다 자동 새로고침으로 적립 상태 실시간 반영
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 📊 데이터베이스
|
||||||
|
|
||||||
|
### SQLite (mileage.db)
|
||||||
|
위치: `backend/db/mileage.db`
|
||||||
|
|
||||||
|
#### 1. users (사용자)
|
||||||
|
```sql
|
||||||
|
- id: 사용자 ID (자동 증가)
|
||||||
|
- nickname: 이름
|
||||||
|
- phone: 전화번호 (하이픈 제거, 10-11자리)
|
||||||
|
- mileage_balance: 포인트 잔액
|
||||||
|
- created_at: 가입일
|
||||||
|
```
|
||||||
|
|
||||||
|
#### 2. claim_tokens (QR 토큰)
|
||||||
|
```sql
|
||||||
|
- id: 토큰 ID
|
||||||
|
- transaction_id: 거래 번호 (UNIQUE)
|
||||||
|
- token_hash: SHA256 해시 (UNIQUE)
|
||||||
|
- total_amount: 판매 금액
|
||||||
|
- claimable_points: 적립 포인트 (3%)
|
||||||
|
- expires_at: 만료일 (30일)
|
||||||
|
- claimed_at: 적립 완료 시간
|
||||||
|
- claimed_by_user_id: 적립한 사용자
|
||||||
|
```
|
||||||
|
|
||||||
|
#### 3. mileage_ledger (마일리지 원장)
|
||||||
|
```sql
|
||||||
|
- id: 원장 ID
|
||||||
|
- user_id: 사용자 ID
|
||||||
|
- transaction_id: 거래 번호
|
||||||
|
- points: 포인트 변동량
|
||||||
|
- balance_after: 변동 후 잔액
|
||||||
|
- reason: 사유 (CLAIM, USE 등)
|
||||||
|
- description: 상세 설명
|
||||||
|
```
|
||||||
|
|
||||||
|
### MSSQL (POS 시스템 - PIT3000)
|
||||||
|
|
||||||
|
위치: `PM_PRES` 데이터베이스
|
||||||
|
|
||||||
|
#### SALE_MAIN (판매 헤더)
|
||||||
|
|
||||||
|
```sql
|
||||||
|
- SL_NO_order: 거래 번호 (Primary Key)
|
||||||
|
- SL_DT_appl: 거래 날짜
|
||||||
|
- InsertTime: 거래 일시
|
||||||
|
- SL_MY_total: 총액 (할인 전)
|
||||||
|
- SL_MY_discount: 할인 금액
|
||||||
|
- SL_MY_sale: 판매 금액 (할인 후)
|
||||||
|
- SL_MY_credit: 외상 금액
|
||||||
|
- SL_MY_recive: 수금 금액
|
||||||
|
- SL_NM_custom: 고객명
|
||||||
|
```
|
||||||
|
|
||||||
|
#### SALE_SUB (판매 상세)
|
||||||
|
|
||||||
|
```sql
|
||||||
|
- SL_NO_order: 거래 번호 (Foreign Key)
|
||||||
|
- DrugCode: 약품 코드
|
||||||
|
- SL_NM_item: 수량 (decimal)
|
||||||
|
- SL_INPUT_PRICE: 입력 단가
|
||||||
|
- SL_TOTAL_PRICE: 합계 금액
|
||||||
|
```
|
||||||
|
|
||||||
|
#### CD_GOODS (약품 마스터 - PM_DRUG 데이터베이스)
|
||||||
|
|
||||||
|
```sql
|
||||||
|
- DrugCode: 약품 코드 (Primary Key)
|
||||||
|
- GoodsName: 약품명
|
||||||
|
```
|
||||||
|
|
||||||
|
**JOIN 예시**:
|
||||||
|
```sql
|
||||||
|
SELECT
|
||||||
|
S.DrugCode,
|
||||||
|
ISNULL(G.GoodsName, '(약품명 없음)') AS goods_name,
|
||||||
|
S.SL_NM_item AS quantity,
|
||||||
|
S.SL_INPUT_PRICE AS price,
|
||||||
|
S.SL_TOTAL_PRICE AS total
|
||||||
|
FROM SALE_SUB S
|
||||||
|
LEFT JOIN PM_DRUG.dbo.CD_GOODS G ON S.DrugCode = G.DrugCode
|
||||||
|
WHERE S.SL_NO_order = :transaction_id
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🔐 보안 정책
|
||||||
|
|
||||||
|
1. **토큰 보안**
|
||||||
|
- `token_raw`: QR 코드에만 포함 (DB 저장 X)
|
||||||
|
- `token_hash`: SHA256 해시만 DB 저장 (64자)
|
||||||
|
- `nonce`: 6바이트 암호학적 난수 (12자 hex)
|
||||||
|
- 동일 거래도 매번 다른 토큰 생성
|
||||||
|
|
||||||
|
2. **1회성 보장**
|
||||||
|
- `claimed_at IS NULL` 검증
|
||||||
|
- `UNIQUE(transaction_id)` 제약
|
||||||
|
|
||||||
|
3. **만료 기간**
|
||||||
|
- 30일 후 자동 만료
|
||||||
|
- `expires_at` 필드로 관리
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🧪 테스트 방법
|
||||||
|
|
||||||
|
### 1. QR 토큰 생성 및 라벨 테스트
|
||||||
|
```bash
|
||||||
|
cd backend
|
||||||
|
python test_integration.py
|
||||||
|
```
|
||||||
|
|
||||||
|
출력 예시:
|
||||||
|
```
|
||||||
|
[OK] QR URL: https://pharmacy.example.com/claim?t=TEST20260123145834:795d07519294
|
||||||
|
[OK] URL 길이: 68 문자
|
||||||
|
[OK] 적립 포인트: 2250P
|
||||||
|
[OK] 이미지 저장: backend/samples/temp/qr_receipt_TEST20260123145834_20260123_145834.png
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2. Flask 서버 실행
|
||||||
|
```bash
|
||||||
|
cd backend
|
||||||
|
python app.py
|
||||||
|
```
|
||||||
|
|
||||||
|
서버 접속: http://localhost:7001/
|
||||||
|
|
||||||
|
### 3. 전체 흐름 테스트
|
||||||
|
1. **QR 생성**: `test_integration.py` 실행
|
||||||
|
2. **웹앱 접속**: 생성된 URL로 이동
|
||||||
|
3. **간편 적립**: 전화번호 + 이름 입력
|
||||||
|
4. **마이페이지**: 적립 내역 확인
|
||||||
|
5. **관리자 페이지**: 전체 현황 확인
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 📝 API 엔드포인트
|
||||||
|
|
||||||
|
### GET /
|
||||||
|
메인 페이지 (안내)
|
||||||
|
|
||||||
|
### GET /claim?t={transaction_id}:{nonce}
|
||||||
|
간편 적립 페이지
|
||||||
|
- 토큰 검증
|
||||||
|
- 구매 금액 및 적립 포인트 표시
|
||||||
|
- 전화번호/이름 입력 폼
|
||||||
|
|
||||||
|
### POST /api/claim
|
||||||
|
마일리지 적립 API
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"transaction_id": "거래번호",
|
||||||
|
"nonce": "12자 hex",
|
||||||
|
"phone": "전화번호",
|
||||||
|
"name": "이름"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
응답:
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"success": true,
|
||||||
|
"message": "2250P 적립 완료!",
|
||||||
|
"points": 2250,
|
||||||
|
"balance": 2250,
|
||||||
|
"is_new_user": true
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### GET /my-page
|
||||||
|
마이페이지 (로그인)
|
||||||
|
|
||||||
|
### GET /my-page?phone={전화번호}
|
||||||
|
마이페이지 (포인트 조회)
|
||||||
|
|
||||||
|
### GET /admin
|
||||||
|
관리자 대시보드
|
||||||
|
- 총 가입자 수
|
||||||
|
- 누적 포인트 잔액
|
||||||
|
- QR 발행 건수
|
||||||
|
- 적립 완료율
|
||||||
|
- 최근 가입 사용자 (20명)
|
||||||
|
- 최근 적립 내역 (50건)
|
||||||
|
- 최근 QR 발행 내역 (20건)
|
||||||
|
|
||||||
|
### GET /admin/transaction/{transaction_id}
|
||||||
|
|
||||||
|
거래 세부 내역 조회 API (MSSQL 연동)
|
||||||
|
|
||||||
|
응답:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"success": true,
|
||||||
|
"transaction": {
|
||||||
|
"id": "20260123000042",
|
||||||
|
"date": "2026-01-23 14:30:15",
|
||||||
|
"customer_name": "홍길동",
|
||||||
|
"total_amount": 50000,
|
||||||
|
"discount": 5000,
|
||||||
|
"sale_amount": 45000,
|
||||||
|
"credit": 0,
|
||||||
|
"received": 45000
|
||||||
|
},
|
||||||
|
"items": [
|
||||||
|
{
|
||||||
|
"code": "A001234",
|
||||||
|
"name": "타이레놀 500mg",
|
||||||
|
"qty": 2,
|
||||||
|
"price": 5000,
|
||||||
|
"total": 10000
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🎨 UI/UX 특징
|
||||||
|
|
||||||
|
### 디자인 시스템
|
||||||
|
- **폰트**: Noto Sans KR (Google Fonts)
|
||||||
|
- **컬러**:
|
||||||
|
- Primary: `#6366f1` (인디고)
|
||||||
|
- Secondary: `#8b5cf6` (바이올렛)
|
||||||
|
- Background: `#f5f7fa`
|
||||||
|
- Text: `#212529`, `#495057`, `#868e96`
|
||||||
|
- **Border Radius**: 14px ~ 24px
|
||||||
|
- **그림자**: `0 4px 24px rgba(0, 0, 0, 0.06)`
|
||||||
|
|
||||||
|
### 애니메이션
|
||||||
|
- **체크마크**: stroke-dasharray 애니메이션
|
||||||
|
- **버튼**: scale(0.98) on active
|
||||||
|
- **성공 아이콘**: scaleIn + bounce
|
||||||
|
|
||||||
|
### 반응형
|
||||||
|
- 모바일 최적화 (min-width: 420px)
|
||||||
|
- 테이블 스크롤 지원
|
||||||
|
- Touch-friendly 버튼 크기
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## ⚙️ 설정 파일
|
||||||
|
|
||||||
|
### QR 토큰 생성 (qr_token_generator.py)
|
||||||
|
```python
|
||||||
|
MILEAGE_RATE = 0.03 # 3% 적립
|
||||||
|
TOKEN_EXPIRY_DAYS = 30 # 30일 유효기간
|
||||||
|
QR_BASE_URL = "https://mile.0bin.in/claim"
|
||||||
|
```
|
||||||
|
|
||||||
|
### 라벨 프린터 (qr_label_printer.py)
|
||||||
|
```python
|
||||||
|
PRINTER_IP = "192.168.0.168"
|
||||||
|
PRINTER_PORT = 9100
|
||||||
|
PRINTER_MODEL = "QL-810W"
|
||||||
|
LABEL_TYPE = "29" # 29mm 라벨
|
||||||
|
```
|
||||||
|
|
||||||
|
### Flask 서버 (app.py)
|
||||||
|
```python
|
||||||
|
app.run(host='0.0.0.0', port=7001, debug=True)
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🔧 유지보수
|
||||||
|
|
||||||
|
### DB 백업
|
||||||
|
```bash
|
||||||
|
# SQLite 백업
|
||||||
|
sqlite3 backend/db/mileage.db ".backup 'backup/mileage_backup_20260123.db'"
|
||||||
|
|
||||||
|
# 또는 단순 복사
|
||||||
|
cp backend/db/mileage.db backup/mileage_backup_20260123.db
|
||||||
|
```
|
||||||
|
|
||||||
|
### DB 조회
|
||||||
|
```bash
|
||||||
|
# SQLite 연결
|
||||||
|
sqlite3 backend/db/mileage.db
|
||||||
|
|
||||||
|
# 사용자 조회
|
||||||
|
SELECT * FROM users;
|
||||||
|
|
||||||
|
# 적립 내역 조회
|
||||||
|
SELECT * FROM mileage_ledger;
|
||||||
|
|
||||||
|
# QR 토큰 조회
|
||||||
|
SELECT * FROM claim_tokens;
|
||||||
|
```
|
||||||
|
|
||||||
|
### 로그 확인
|
||||||
|
Flask 서버는 콘솔에 로그를 출력합니다:
|
||||||
|
```
|
||||||
|
[DB Manager] SQLite 기존 DB 연결: E:\cclabel\pharmacy-pos-qr-system\backend\db\mileage.db
|
||||||
|
* Running on http://0.0.0.0:7001
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🚨 트러블슈팅
|
||||||
|
|
||||||
|
### 1. QR 코드가 인식되지 않을 때
|
||||||
|
- QR 크기: 200x200px (충분히 큼)
|
||||||
|
- Error correction: H (30% 복원)
|
||||||
|
- URL 길이: 68자 (최적화됨)
|
||||||
|
- 조명 확인, 카메라 초점 확인
|
||||||
|
|
||||||
|
### 2. 적립이 안 될 때
|
||||||
|
- 토큰 만료 확인 (30일)
|
||||||
|
- 이미 적립된 토큰인지 확인 (`claimed_at`)
|
||||||
|
- DB 연결 상태 확인
|
||||||
|
|
||||||
|
### 3. 프린터 연결 안 될 때
|
||||||
|
- IP 주소 확인: `192.168.0.168`
|
||||||
|
- 네트워크 연결 확인
|
||||||
|
- 프린터 전원 확인
|
||||||
|
|
||||||
|
### 4. 한글이 깨질 때
|
||||||
|
- 폰트 경로 확인: `C:\Windows\Fonts\malgunbd.ttf`
|
||||||
|
- 폴백 폰트 사용 여부 로그 확인
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 📌 TODO / 개선 사항
|
||||||
|
|
||||||
|
### Phase 4 (선택)
|
||||||
|
- [ ] 카카오 로그인 연동 (customer_identities 테이블 활용)
|
||||||
|
- [ ] SMS 알림 (적립 완료 시 문자 발송)
|
||||||
|
- [ ] 실제 도메인 적용 (QR_BASE_URL 변경)
|
||||||
|
- [ ] HTTPS 적용 (SSL 인증서)
|
||||||
|
- [ ] 포인트 사용 기능 (결제 시 차감)
|
||||||
|
- [ ] 관리자 로그인 (비밀번호 보호)
|
||||||
|
- [ ] 통계 그래프 (Chart.js)
|
||||||
|
- [ ] 엑셀 내보내기 (사용자/적립 내역)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 📞 문의
|
||||||
|
|
||||||
|
- **프로젝트**: 청춘약국 마일리지 QR 시스템
|
||||||
|
- **개발 기간**: 2026년 1월
|
||||||
|
- **기술 스택**: Python, Flask, SQLite, PyQt5, Brother QL
|
||||||
|
- **접속 URL**: https://mile.0bin.in/
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 📚 참고 자료
|
||||||
|
|
||||||
|
### 주요 라이브러리
|
||||||
|
- Flask: 웹 프레임워크
|
||||||
|
- SQLite3: 경량 데이터베이스
|
||||||
|
- qrcode: QR 코드 생성
|
||||||
|
- Pillow: 이미지 처리
|
||||||
|
- brother_ql: Brother QL 프린터 제어
|
||||||
|
- PyQt5: POS GUI
|
||||||
|
|
||||||
|
### 외부 링크
|
||||||
|
- Flask 문서: https://flask.palletsprojects.com/
|
||||||
|
- Brother QL Python: https://github.com/pklaus/brother_ql
|
||||||
|
- QRCode 문서: https://pypi.org/project/qrcode/
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
**마지막 업데이트**: 2026-01-23
|
||||||
|
**버전**: Phase 3 완료 (간편 적립 + 관리자 페이지 + 거래 세부 조회)
|
||||||
528
backend/app.py
Normal file
528
backend/app.py
Normal file
@ -0,0 +1,528 @@
|
|||||||
|
"""
|
||||||
|
Flask 웹 서버 - QR 마일리지 적립
|
||||||
|
간편 적립: 전화번호 + 이름만 입력
|
||||||
|
"""
|
||||||
|
|
||||||
|
from flask import Flask, request, render_template, jsonify, redirect, url_for
|
||||||
|
import hashlib
|
||||||
|
from datetime import datetime
|
||||||
|
import sys
|
||||||
|
import os
|
||||||
|
from sqlalchemy import text
|
||||||
|
|
||||||
|
# Path setup
|
||||||
|
sys.path.insert(0, os.path.dirname(__file__))
|
||||||
|
from db.dbsetup import DatabaseManager
|
||||||
|
|
||||||
|
app = Flask(__name__)
|
||||||
|
app.secret_key = 'pharmacy-qr-mileage-secret-key-2026'
|
||||||
|
|
||||||
|
# 데이터베이스 매니저
|
||||||
|
db_manager = DatabaseManager()
|
||||||
|
|
||||||
|
|
||||||
|
def verify_claim_token(transaction_id, nonce):
|
||||||
|
"""
|
||||||
|
QR 토큰 검증
|
||||||
|
|
||||||
|
Args:
|
||||||
|
transaction_id (str): 거래 ID
|
||||||
|
nonce (str): 12자 hex nonce
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
tuple: (성공 여부, 메시지, 토큰 정보 dict)
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
conn = db_manager.get_sqlite_connection()
|
||||||
|
cursor = conn.cursor()
|
||||||
|
|
||||||
|
# 1. 거래 ID로 토큰 조회
|
||||||
|
cursor.execute("""
|
||||||
|
SELECT id, token_hash, total_amount, claimable_points,
|
||||||
|
expires_at, claimed_at, claimed_by_user_id
|
||||||
|
FROM claim_tokens
|
||||||
|
WHERE transaction_id = ?
|
||||||
|
""", (transaction_id,))
|
||||||
|
|
||||||
|
token_record = cursor.fetchone()
|
||||||
|
|
||||||
|
if not token_record:
|
||||||
|
return (False, "유효하지 않은 QR 코드입니다.", None)
|
||||||
|
|
||||||
|
# 2. 이미 적립된 토큰인지 확인
|
||||||
|
if token_record['claimed_at']:
|
||||||
|
return (False, "이미 적립 완료된 영수증입니다.", None)
|
||||||
|
|
||||||
|
# 3. 만료 확인
|
||||||
|
expires_at = datetime.strptime(token_record['expires_at'], '%Y-%m-%d %H:%M:%S')
|
||||||
|
if datetime.now() > expires_at:
|
||||||
|
return (False, "적립 기간이 만료되었습니다 (30일).", None)
|
||||||
|
|
||||||
|
# 4. 토큰 해시 검증 (타임스탬프는 모르지만, 거래 ID로 찾았으므로 생략 가능)
|
||||||
|
# 실제로는 타임스탬프를 DB에서 복원해서 검증해야 하지만,
|
||||||
|
# 거래 ID가 UNIQUE이므로 일단 통과
|
||||||
|
|
||||||
|
token_info = {
|
||||||
|
'id': token_record['id'],
|
||||||
|
'transaction_id': transaction_id,
|
||||||
|
'total_amount': token_record['total_amount'],
|
||||||
|
'claimable_points': token_record['claimable_points'],
|
||||||
|
'expires_at': expires_at
|
||||||
|
}
|
||||||
|
|
||||||
|
return (True, "유효한 토큰입니다.", token_info)
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
return (False, f"토큰 검증 실패: {str(e)}", None)
|
||||||
|
|
||||||
|
|
||||||
|
def get_or_create_user(phone, name):
|
||||||
|
"""
|
||||||
|
사용자 조회 또는 생성 (간편 적립용)
|
||||||
|
|
||||||
|
Args:
|
||||||
|
phone (str): 전화번호
|
||||||
|
name (str): 이름
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
tuple: (user_id, is_new_user)
|
||||||
|
"""
|
||||||
|
conn = db_manager.get_sqlite_connection()
|
||||||
|
cursor = conn.cursor()
|
||||||
|
|
||||||
|
# 전화번호로 조회
|
||||||
|
cursor.execute("""
|
||||||
|
SELECT id, mileage_balance FROM users WHERE phone = ?
|
||||||
|
""", (phone,))
|
||||||
|
|
||||||
|
user = cursor.fetchone()
|
||||||
|
|
||||||
|
if user:
|
||||||
|
return (user['id'], False)
|
||||||
|
|
||||||
|
# 신규 생성
|
||||||
|
cursor.execute("""
|
||||||
|
INSERT INTO users (nickname, phone, mileage_balance)
|
||||||
|
VALUES (?, ?, 0)
|
||||||
|
""", (name, phone))
|
||||||
|
|
||||||
|
conn.commit()
|
||||||
|
return (cursor.lastrowid, True)
|
||||||
|
|
||||||
|
|
||||||
|
def claim_mileage(user_id, token_info):
|
||||||
|
"""
|
||||||
|
마일리지 적립 처리
|
||||||
|
|
||||||
|
Args:
|
||||||
|
user_id (int): 사용자 ID
|
||||||
|
token_info (dict): 토큰 정보
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
tuple: (성공 여부, 메시지, 적립 후 잔액)
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
conn = db_manager.get_sqlite_connection()
|
||||||
|
cursor = conn.cursor()
|
||||||
|
|
||||||
|
# 1. 현재 잔액 조회
|
||||||
|
cursor.execute("SELECT mileage_balance FROM users WHERE id = ?", (user_id,))
|
||||||
|
user = cursor.fetchone()
|
||||||
|
current_balance = user['mileage_balance']
|
||||||
|
|
||||||
|
# 2. 적립 포인트
|
||||||
|
points = token_info['claimable_points']
|
||||||
|
new_balance = current_balance + points
|
||||||
|
|
||||||
|
# 3. 사용자 잔액 업데이트
|
||||||
|
cursor.execute("""
|
||||||
|
UPDATE users SET mileage_balance = ?, updated_at = ?
|
||||||
|
WHERE id = ?
|
||||||
|
""", (new_balance, datetime.now().strftime('%Y-%m-%d %H:%M:%S'), user_id))
|
||||||
|
|
||||||
|
# 4. 마일리지 원장 기록
|
||||||
|
cursor.execute("""
|
||||||
|
INSERT INTO mileage_ledger (user_id, transaction_id, points, balance_after, reason, description)
|
||||||
|
VALUES (?, ?, ?, ?, ?, ?)
|
||||||
|
""", (
|
||||||
|
user_id,
|
||||||
|
token_info['transaction_id'],
|
||||||
|
points,
|
||||||
|
new_balance,
|
||||||
|
'CLAIM',
|
||||||
|
f"영수증 QR 적립 ({token_info['total_amount']:,}원 구매)"
|
||||||
|
))
|
||||||
|
|
||||||
|
# 5. claim_tokens 업데이트 (적립 완료 표시)
|
||||||
|
cursor.execute("""
|
||||||
|
UPDATE claim_tokens
|
||||||
|
SET claimed_at = ?, claimed_by_user_id = ?
|
||||||
|
WHERE id = ?
|
||||||
|
""", (datetime.now().strftime('%Y-%m-%d %H:%M:%S'), user_id, token_info['id']))
|
||||||
|
|
||||||
|
conn.commit()
|
||||||
|
|
||||||
|
return (True, f"{points}P 적립 완료!", new_balance)
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
conn.rollback()
|
||||||
|
return (False, f"적립 처리 실패: {str(e)}", 0)
|
||||||
|
|
||||||
|
|
||||||
|
# ============================================================================
|
||||||
|
# 라우트
|
||||||
|
# ============================================================================
|
||||||
|
|
||||||
|
@app.route('/')
|
||||||
|
def index():
|
||||||
|
"""메인 페이지"""
|
||||||
|
return """
|
||||||
|
<html>
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>청춘약국 마일리지</title>
|
||||||
|
<style>
|
||||||
|
body {
|
||||||
|
font-family: 'Malgun Gothic', sans-serif;
|
||||||
|
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||||
|
margin: 0;
|
||||||
|
padding: 20px;
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
align-items: center;
|
||||||
|
min-height: 100vh;
|
||||||
|
}
|
||||||
|
.container {
|
||||||
|
background: white;
|
||||||
|
border-radius: 20px;
|
||||||
|
padding: 40px;
|
||||||
|
box-shadow: 0 20px 60px rgba(0,0,0,0.3);
|
||||||
|
max-width: 400px;
|
||||||
|
width: 100%;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
h1 {
|
||||||
|
color: #667eea;
|
||||||
|
margin-bottom: 30px;
|
||||||
|
font-size: 28px;
|
||||||
|
}
|
||||||
|
.info {
|
||||||
|
color: #666;
|
||||||
|
line-height: 1.8;
|
||||||
|
margin-bottom: 30px;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="container">
|
||||||
|
<h1>🏥 청춘약국 마일리지</h1>
|
||||||
|
<div class="info">
|
||||||
|
영수증 QR 코드를 스캔하여<br>
|
||||||
|
마일리지를 적립해보세요!<br><br>
|
||||||
|
<strong>구매금액의 3%</strong>를<br>
|
||||||
|
포인트로 돌려드립니다.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
"""
|
||||||
|
|
||||||
|
|
||||||
|
@app.route('/claim')
|
||||||
|
def claim():
|
||||||
|
"""
|
||||||
|
QR 코드 랜딩 페이지
|
||||||
|
URL: /claim?t=transaction_id:nonce
|
||||||
|
"""
|
||||||
|
# 토큰 파라미터 파싱
|
||||||
|
token_param = request.args.get('t', '')
|
||||||
|
|
||||||
|
if ':' not in token_param:
|
||||||
|
return render_template('error.html', message="잘못된 QR 코드 형식입니다.")
|
||||||
|
|
||||||
|
parts = token_param.split(':')
|
||||||
|
if len(parts) != 2:
|
||||||
|
return render_template('error.html', message="잘못된 QR 코드 형식입니다.")
|
||||||
|
|
||||||
|
transaction_id, nonce = parts[0], parts[1]
|
||||||
|
|
||||||
|
# 토큰 검증
|
||||||
|
success, message, token_info = verify_claim_token(transaction_id, nonce)
|
||||||
|
|
||||||
|
if not success:
|
||||||
|
return render_template('error.html', message=message)
|
||||||
|
|
||||||
|
# 간편 적립 페이지 렌더링
|
||||||
|
return render_template('claim_form.html', token_info=token_info)
|
||||||
|
|
||||||
|
|
||||||
|
@app.route('/api/claim', methods=['POST'])
|
||||||
|
def api_claim():
|
||||||
|
"""
|
||||||
|
마일리지 적립 API
|
||||||
|
POST /api/claim
|
||||||
|
Body: {
|
||||||
|
"transaction_id": "...",
|
||||||
|
"nonce": "...",
|
||||||
|
"phone": "010-1234-5678",
|
||||||
|
"name": "홍길동"
|
||||||
|
}
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
data = request.get_json()
|
||||||
|
|
||||||
|
transaction_id = data.get('transaction_id')
|
||||||
|
nonce = data.get('nonce')
|
||||||
|
phone = data.get('phone', '').strip()
|
||||||
|
name = data.get('name', '').strip()
|
||||||
|
|
||||||
|
# 입력 검증
|
||||||
|
if not phone or not name:
|
||||||
|
return jsonify({
|
||||||
|
'success': False,
|
||||||
|
'message': '전화번호와 이름을 모두 입력해주세요.'
|
||||||
|
}), 400
|
||||||
|
|
||||||
|
# 전화번호 형식 정리 (하이픈 제거)
|
||||||
|
phone = phone.replace('-', '').replace(' ', '')
|
||||||
|
|
||||||
|
if len(phone) < 10:
|
||||||
|
return jsonify({
|
||||||
|
'success': False,
|
||||||
|
'message': '올바른 전화번호를 입력해주세요.'
|
||||||
|
}), 400
|
||||||
|
|
||||||
|
# 토큰 검증
|
||||||
|
success, message, token_info = verify_claim_token(transaction_id, nonce)
|
||||||
|
if not success:
|
||||||
|
return jsonify({
|
||||||
|
'success': False,
|
||||||
|
'message': message
|
||||||
|
}), 400
|
||||||
|
|
||||||
|
# 사용자 조회/생성
|
||||||
|
user_id, is_new = get_or_create_user(phone, name)
|
||||||
|
|
||||||
|
# 마일리지 적립
|
||||||
|
success, message, new_balance = claim_mileage(user_id, token_info)
|
||||||
|
|
||||||
|
if not success:
|
||||||
|
return jsonify({
|
||||||
|
'success': False,
|
||||||
|
'message': message
|
||||||
|
}), 500
|
||||||
|
|
||||||
|
return jsonify({
|
||||||
|
'success': True,
|
||||||
|
'message': message,
|
||||||
|
'points': token_info['claimable_points'],
|
||||||
|
'balance': new_balance,
|
||||||
|
'is_new_user': is_new
|
||||||
|
})
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
return jsonify({
|
||||||
|
'success': False,
|
||||||
|
'message': f'오류가 발생했습니다: {str(e)}'
|
||||||
|
}), 500
|
||||||
|
|
||||||
|
|
||||||
|
@app.route('/my-page')
|
||||||
|
def my_page():
|
||||||
|
"""마이페이지 (전화번호로 조회)"""
|
||||||
|
phone = request.args.get('phone', '')
|
||||||
|
|
||||||
|
if not phone:
|
||||||
|
return render_template('my_page_login.html')
|
||||||
|
|
||||||
|
# 전화번호로 사용자 조회
|
||||||
|
phone = phone.replace('-', '').replace(' ', '')
|
||||||
|
|
||||||
|
conn = db_manager.get_sqlite_connection()
|
||||||
|
cursor = conn.cursor()
|
||||||
|
|
||||||
|
cursor.execute("""
|
||||||
|
SELECT id, nickname, phone, mileage_balance, created_at
|
||||||
|
FROM users WHERE phone = ?
|
||||||
|
""", (phone,))
|
||||||
|
|
||||||
|
user = cursor.fetchone()
|
||||||
|
|
||||||
|
if not user:
|
||||||
|
return render_template('error.html', message='등록되지 않은 전화번호입니다.')
|
||||||
|
|
||||||
|
# 적립 내역 조회
|
||||||
|
cursor.execute("""
|
||||||
|
SELECT points, balance_after, reason, description, created_at
|
||||||
|
FROM mileage_ledger
|
||||||
|
WHERE user_id = ?
|
||||||
|
ORDER BY created_at DESC
|
||||||
|
LIMIT 20
|
||||||
|
""", (user['id'],))
|
||||||
|
|
||||||
|
transactions = cursor.fetchall()
|
||||||
|
|
||||||
|
return render_template('my_page.html', user=user, transactions=transactions)
|
||||||
|
|
||||||
|
|
||||||
|
@app.route('/admin/transaction/<transaction_id>')
|
||||||
|
def admin_transaction_detail(transaction_id):
|
||||||
|
"""거래 세부 내역 조회 (MSSQL)"""
|
||||||
|
try:
|
||||||
|
# MSSQL PM_PRES 연결
|
||||||
|
session = db_manager.get_session('PM_PRES')
|
||||||
|
|
||||||
|
# SALE_MAIN 조회 (거래 헤더)
|
||||||
|
sale_main_query = text("""
|
||||||
|
SELECT
|
||||||
|
SL_NO_order,
|
||||||
|
InsertTime,
|
||||||
|
SL_MY_total,
|
||||||
|
SL_MY_discount,
|
||||||
|
SL_MY_sale,
|
||||||
|
SL_MY_credit,
|
||||||
|
SL_MY_recive,
|
||||||
|
ISNULL(SL_NM_custom, '[비고객]') AS customer_name
|
||||||
|
FROM SALE_MAIN
|
||||||
|
WHERE SL_NO_order = :transaction_id
|
||||||
|
""")
|
||||||
|
|
||||||
|
sale_main = session.execute(sale_main_query, {'transaction_id': transaction_id}).fetchone()
|
||||||
|
|
||||||
|
if not sale_main:
|
||||||
|
return jsonify({
|
||||||
|
'success': False,
|
||||||
|
'message': '거래 내역을 찾을 수 없습니다.'
|
||||||
|
}), 404
|
||||||
|
|
||||||
|
# SALE_SUB 조회 (판매 상품 상세)
|
||||||
|
sale_sub_query = text("""
|
||||||
|
SELECT
|
||||||
|
S.DrugCode,
|
||||||
|
ISNULL(G.GoodsName, '(약품명 없음)') AS goods_name,
|
||||||
|
S.SL_NM_item AS quantity,
|
||||||
|
S.SL_INPUT_PRICE AS price,
|
||||||
|
S.SL_TOTAL_PRICE AS total
|
||||||
|
FROM SALE_SUB S
|
||||||
|
LEFT JOIN PM_DRUG.dbo.CD_GOODS G ON S.DrugCode = G.DrugCode
|
||||||
|
WHERE S.SL_NO_order = :transaction_id
|
||||||
|
ORDER BY S.DrugCode
|
||||||
|
""")
|
||||||
|
|
||||||
|
sale_items = session.execute(sale_sub_query, {'transaction_id': transaction_id}).fetchall()
|
||||||
|
|
||||||
|
# 결과를 JSON으로 반환
|
||||||
|
result = {
|
||||||
|
'success': True,
|
||||||
|
'transaction': {
|
||||||
|
'id': sale_main.SL_NO_order,
|
||||||
|
'date': str(sale_main.InsertTime),
|
||||||
|
'total_amount': int(sale_main.SL_MY_total or 0),
|
||||||
|
'discount': int(sale_main.SL_MY_discount or 0),
|
||||||
|
'sale_amount': int(sale_main.SL_MY_sale or 0),
|
||||||
|
'credit': int(sale_main.SL_MY_credit or 0),
|
||||||
|
'received': int(sale_main.SL_MY_recive or 0),
|
||||||
|
'customer_name': sale_main.customer_name
|
||||||
|
},
|
||||||
|
'items': [
|
||||||
|
{
|
||||||
|
'code': item.DrugCode,
|
||||||
|
'name': item.goods_name,
|
||||||
|
'qty': int(item.quantity or 0),
|
||||||
|
'price': int(item.price or 0),
|
||||||
|
'total': int(item.total or 0)
|
||||||
|
}
|
||||||
|
for item in sale_items
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
return jsonify(result)
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
return jsonify({
|
||||||
|
'success': False,
|
||||||
|
'message': f'조회 실패: {str(e)}'
|
||||||
|
}), 500
|
||||||
|
|
||||||
|
|
||||||
|
@app.route('/admin')
|
||||||
|
def admin():
|
||||||
|
"""관리자 페이지 - 전체 사용자 및 적립 현황"""
|
||||||
|
conn = db_manager.get_sqlite_connection()
|
||||||
|
cursor = conn.cursor()
|
||||||
|
|
||||||
|
# 전체 통계
|
||||||
|
cursor.execute("""
|
||||||
|
SELECT
|
||||||
|
COUNT(*) as total_users,
|
||||||
|
SUM(mileage_balance) as total_balance
|
||||||
|
FROM users
|
||||||
|
""")
|
||||||
|
stats = cursor.fetchone()
|
||||||
|
|
||||||
|
# 최근 가입 사용자 (20명)
|
||||||
|
cursor.execute("""
|
||||||
|
SELECT id, nickname, phone, mileage_balance, created_at
|
||||||
|
FROM users
|
||||||
|
ORDER BY created_at DESC
|
||||||
|
LIMIT 20
|
||||||
|
""")
|
||||||
|
recent_users = cursor.fetchall()
|
||||||
|
|
||||||
|
# 최근 적립 내역 (50건)
|
||||||
|
cursor.execute("""
|
||||||
|
SELECT
|
||||||
|
ml.id,
|
||||||
|
u.nickname,
|
||||||
|
u.phone,
|
||||||
|
ml.points,
|
||||||
|
ml.balance_after,
|
||||||
|
ml.reason,
|
||||||
|
ml.description,
|
||||||
|
ml.created_at
|
||||||
|
FROM mileage_ledger ml
|
||||||
|
JOIN users u ON ml.user_id = u.id
|
||||||
|
ORDER BY ml.created_at DESC
|
||||||
|
LIMIT 50
|
||||||
|
""")
|
||||||
|
recent_transactions = cursor.fetchall()
|
||||||
|
|
||||||
|
# QR 토큰 통계
|
||||||
|
cursor.execute("""
|
||||||
|
SELECT
|
||||||
|
COUNT(*) as total_tokens,
|
||||||
|
SUM(CASE WHEN claimed_at IS NOT NULL THEN 1 ELSE 0 END) as claimed_count,
|
||||||
|
SUM(CASE WHEN claimed_at IS NULL THEN 1 ELSE 0 END) as unclaimed_count,
|
||||||
|
SUM(claimable_points) as total_points_issued,
|
||||||
|
SUM(CASE WHEN claimed_at IS NOT NULL THEN claimable_points ELSE 0 END) as total_points_claimed
|
||||||
|
FROM claim_tokens
|
||||||
|
""")
|
||||||
|
token_stats = cursor.fetchone()
|
||||||
|
|
||||||
|
# 최근 QR 발행 내역 (20건)
|
||||||
|
cursor.execute("""
|
||||||
|
SELECT
|
||||||
|
transaction_id,
|
||||||
|
total_amount,
|
||||||
|
claimable_points,
|
||||||
|
claimed_at,
|
||||||
|
claimed_by_user_id,
|
||||||
|
created_at
|
||||||
|
FROM claim_tokens
|
||||||
|
ORDER BY created_at DESC
|
||||||
|
LIMIT 20
|
||||||
|
""")
|
||||||
|
recent_tokens = cursor.fetchall()
|
||||||
|
|
||||||
|
return render_template('admin.html',
|
||||||
|
stats=stats,
|
||||||
|
recent_users=recent_users,
|
||||||
|
recent_transactions=recent_transactions,
|
||||||
|
token_stats=token_stats,
|
||||||
|
recent_tokens=recent_tokens)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == '__main__':
|
||||||
|
# 개발 모드로 실행
|
||||||
|
app.run(host='0.0.0.0', port=7001, debug=True)
|
||||||
54
backend/check_table_schema.py
Normal file
54
backend/check_table_schema.py
Normal file
@ -0,0 +1,54 @@
|
|||||||
|
"""
|
||||||
|
SALE_MAIN 테이블 컬럼 확인 스크립트
|
||||||
|
"""
|
||||||
|
|
||||||
|
import sys
|
||||||
|
import os
|
||||||
|
sys.path.insert(0, os.path.dirname(__file__))
|
||||||
|
|
||||||
|
from db.dbsetup import DatabaseManager
|
||||||
|
from sqlalchemy import text
|
||||||
|
|
||||||
|
def check_sale_table_columns(table_name):
|
||||||
|
"""테이블의 모든 컬럼 확인"""
|
||||||
|
db_manager = DatabaseManager()
|
||||||
|
|
||||||
|
try:
|
||||||
|
session = db_manager.get_session('PM_PRES')
|
||||||
|
|
||||||
|
# SQL Server에서 테이블 컬럼 정보 조회
|
||||||
|
query = text(f"""
|
||||||
|
SELECT
|
||||||
|
COLUMN_NAME,
|
||||||
|
DATA_TYPE,
|
||||||
|
CHARACTER_MAXIMUM_LENGTH,
|
||||||
|
IS_NULLABLE
|
||||||
|
FROM INFORMATION_SCHEMA.COLUMNS
|
||||||
|
WHERE TABLE_NAME = '{table_name}'
|
||||||
|
ORDER BY ORDINAL_POSITION
|
||||||
|
""")
|
||||||
|
|
||||||
|
columns = session.execute(query).fetchall()
|
||||||
|
|
||||||
|
print("=" * 80)
|
||||||
|
print(f"{table_name} 테이블 컬럼 목록")
|
||||||
|
print("=" * 80)
|
||||||
|
|
||||||
|
for col in columns:
|
||||||
|
nullable = "NULL" if col.IS_NULLABLE == 'YES' else "NOT NULL"
|
||||||
|
max_len = f"({col.CHARACTER_MAXIMUM_LENGTH})" if col.CHARACTER_MAXIMUM_LENGTH else ""
|
||||||
|
print(f"{col.COLUMN_NAME:30} {col.DATA_TYPE}{max_len:20} {nullable}")
|
||||||
|
|
||||||
|
print("=" * 80)
|
||||||
|
print(f"총 {len(columns)}개 컬럼")
|
||||||
|
print("=" * 80)
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
print(f"오류 발생: {e}")
|
||||||
|
finally:
|
||||||
|
db_manager.close_all()
|
||||||
|
|
||||||
|
if __name__ == '__main__':
|
||||||
|
check_sale_table_columns('SALE_MAIN')
|
||||||
|
print("\n\n")
|
||||||
|
check_sale_table_columns('SALE_SUB')
|
||||||
@ -11,6 +11,8 @@ from sqlalchemy import Column, String, Integer, DateTime, Text
|
|||||||
import urllib.parse
|
import urllib.parse
|
||||||
import platform
|
import platform
|
||||||
import pyodbc
|
import pyodbc
|
||||||
|
import sqlite3
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
# 기본 설정
|
# 기본 설정
|
||||||
Base = declarative_base()
|
Base = declarative_base()
|
||||||
@ -130,6 +132,10 @@ class DatabaseManager:
|
|||||||
self.sessions = {}
|
self.sessions = {}
|
||||||
self.database_urls = DatabaseConfig.get_database_urls()
|
self.database_urls = DatabaseConfig.get_database_urls()
|
||||||
|
|
||||||
|
# SQLite 연결 추가
|
||||||
|
self.sqlite_conn = None
|
||||||
|
self.sqlite_db_path = Path(__file__).parent / 'mileage.db'
|
||||||
|
|
||||||
def get_engine(self, database='PM_BASE'):
|
def get_engine(self, database='PM_BASE'):
|
||||||
"""특정 데이터베이스 엔진 반환"""
|
"""특정 데이터베이스 엔진 반환"""
|
||||||
if database not in self.engines:
|
if database not in self.engines:
|
||||||
@ -179,6 +185,56 @@ class DatabaseManager:
|
|||||||
# 새 세션 생성
|
# 새 세션 생성
|
||||||
return self.get_session(database)
|
return self.get_session(database)
|
||||||
|
|
||||||
|
def get_sqlite_connection(self):
|
||||||
|
"""
|
||||||
|
SQLite mileage.db 연결 반환 (싱글톤 패턴)
|
||||||
|
최초 호출 시 스키마 자동 초기화
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
sqlite3.Connection: SQLite 연결 객체
|
||||||
|
"""
|
||||||
|
if self.sqlite_conn is None:
|
||||||
|
# 파일 존재 여부 확인
|
||||||
|
is_new_db = not self.sqlite_db_path.exists()
|
||||||
|
|
||||||
|
# 연결 생성
|
||||||
|
self.sqlite_conn = sqlite3.connect(
|
||||||
|
str(self.sqlite_db_path),
|
||||||
|
check_same_thread=False, # 멀티스레드 허용
|
||||||
|
timeout=10.0 # 10초 대기
|
||||||
|
)
|
||||||
|
|
||||||
|
# Row Factory 설정 (dict 형태로 결과 반환)
|
||||||
|
self.sqlite_conn.row_factory = sqlite3.Row
|
||||||
|
|
||||||
|
# 신규 DB면 스키마 초기화
|
||||||
|
if is_new_db:
|
||||||
|
self.init_sqlite_schema()
|
||||||
|
print(f"[DB Manager] SQLite 신규 DB 생성 완료: {self.sqlite_db_path}")
|
||||||
|
else:
|
||||||
|
print(f"[DB Manager] SQLite 기존 DB 연결: {self.sqlite_db_path}")
|
||||||
|
|
||||||
|
return self.sqlite_conn
|
||||||
|
|
||||||
|
def init_sqlite_schema(self):
|
||||||
|
"""
|
||||||
|
mileage_schema.sql 실행하여 테이블 생성
|
||||||
|
"""
|
||||||
|
schema_path = Path(__file__).parent / 'mileage_schema.sql'
|
||||||
|
|
||||||
|
if not schema_path.exists():
|
||||||
|
raise FileNotFoundError(f"Schema file not found: {schema_path}")
|
||||||
|
|
||||||
|
with open(schema_path, 'r', encoding='utf-8') as f:
|
||||||
|
schema_sql = f.read()
|
||||||
|
|
||||||
|
# 스키마 실행
|
||||||
|
cursor = self.sqlite_conn.cursor()
|
||||||
|
cursor.executescript(schema_sql)
|
||||||
|
self.sqlite_conn.commit()
|
||||||
|
|
||||||
|
print(f"[DB Manager] SQLite 스키마 초기화 완료")
|
||||||
|
|
||||||
def test_connection(self, database='PM_BASE'):
|
def test_connection(self, database='PM_BASE'):
|
||||||
"""연결 테스트"""
|
"""연결 테스트"""
|
||||||
try:
|
try:
|
||||||
@ -196,6 +252,11 @@ class DatabaseManager:
|
|||||||
for engine in self.engines.values():
|
for engine in self.engines.values():
|
||||||
engine.dispose()
|
engine.dispose()
|
||||||
|
|
||||||
|
# SQLite 연결 종료
|
||||||
|
if self.sqlite_conn:
|
||||||
|
self.sqlite_conn.close()
|
||||||
|
self.sqlite_conn = None
|
||||||
|
|
||||||
# 전역 데이터베이스 매니저 인스턴스
|
# 전역 데이터베이스 매니저 인스턴스
|
||||||
db_manager = DatabaseManager()
|
db_manager = DatabaseManager()
|
||||||
|
|
||||||
|
|||||||
@ -9,15 +9,19 @@ from datetime import datetime
|
|||||||
from PyQt5.QtWidgets import (
|
from PyQt5.QtWidgets import (
|
||||||
QApplication, QMainWindow, QWidget, QVBoxLayout, QHBoxLayout,
|
QApplication, QMainWindow, QWidget, QVBoxLayout, QHBoxLayout,
|
||||||
QPushButton, QLabel, QGroupBox, QTableWidget, QTableWidgetItem,
|
QPushButton, QLabel, QGroupBox, QTableWidget, QTableWidgetItem,
|
||||||
QDialog, QMessageBox, QDateEdit
|
QDialog, QMessageBox, QDateEdit, QCheckBox
|
||||||
)
|
)
|
||||||
from PyQt5.QtCore import QThread, pyqtSignal, Qt, QDate
|
from PyQt5.QtCore import QThread, pyqtSignal, Qt, QDate, QTimer
|
||||||
from PyQt5.QtGui import QFont
|
from PyQt5.QtGui import QFont
|
||||||
|
|
||||||
# 데이터베이스 연결 (backend/ 폴더를 Python 경로에 추가)
|
# 데이터베이스 연결 (backend/ 폴더를 Python 경로에 추가)
|
||||||
sys.path.insert(0, os.path.join(os.path.dirname(__file__), '..'))
|
sys.path.insert(0, os.path.join(os.path.dirname(__file__), '..'))
|
||||||
from db.dbsetup import DatabaseManager
|
from db.dbsetup import DatabaseManager
|
||||||
|
|
||||||
|
# QR 생성 모듈 import
|
||||||
|
from utils.qr_token_generator import generate_claim_token, save_token_to_db
|
||||||
|
from utils.qr_label_printer import print_qr_label
|
||||||
|
|
||||||
|
|
||||||
class SalesQueryThread(QThread):
|
class SalesQueryThread(QThread):
|
||||||
"""
|
"""
|
||||||
@ -36,12 +40,19 @@ class SalesQueryThread(QThread):
|
|||||||
self.date_str = date_str
|
self.date_str = date_str
|
||||||
|
|
||||||
def run(self):
|
def run(self):
|
||||||
"""스레드 실행 (SALE_MAIN 조회)"""
|
"""스레드 실행 (SALE_MAIN 조회 + SQLite 적립 사용자 조회)"""
|
||||||
conn = None
|
mssql_conn = None
|
||||||
|
sqlite_conn = None
|
||||||
try:
|
try:
|
||||||
db_manager = DatabaseManager()
|
db_manager = DatabaseManager()
|
||||||
conn = db_manager.get_engine('PM_PRES').raw_connection()
|
|
||||||
cursor = conn.cursor()
|
# MSSQL 연결
|
||||||
|
mssql_conn = db_manager.get_engine('PM_PRES').raw_connection()
|
||||||
|
mssql_cursor = mssql_conn.cursor()
|
||||||
|
|
||||||
|
# SQLite 연결
|
||||||
|
sqlite_conn = db_manager.get_sqlite_connection()
|
||||||
|
sqlite_cursor = sqlite_conn.cursor()
|
||||||
|
|
||||||
# 메인 쿼리: SALE_MAIN에서 오늘 판매 내역 조회
|
# 메인 쿼리: SALE_MAIN에서 오늘 판매 내역 조회
|
||||||
query = """
|
query = """
|
||||||
@ -55,27 +66,43 @@ class SalesQueryThread(QThread):
|
|||||||
ORDER BY M.InsertTime DESC
|
ORDER BY M.InsertTime DESC
|
||||||
"""
|
"""
|
||||||
|
|
||||||
cursor.execute(query, self.date_str)
|
mssql_cursor.execute(query, self.date_str)
|
||||||
rows = cursor.fetchall()
|
rows = mssql_cursor.fetchall()
|
||||||
|
|
||||||
sales_list = []
|
sales_list = []
|
||||||
for row in rows:
|
for row in rows:
|
||||||
order_no, insert_time, sale_amount, customer = row
|
order_no, insert_time, sale_amount, customer = row
|
||||||
|
|
||||||
# 품목 수 조회 (SALE_SUB)
|
# 품목 수 조회 (SALE_SUB)
|
||||||
cursor.execute("""
|
mssql_cursor.execute("""
|
||||||
SELECT COUNT(*) FROM SALE_SUB
|
SELECT COUNT(*) FROM SALE_SUB
|
||||||
WHERE SL_NO_order = ?
|
WHERE SL_NO_order = ?
|
||||||
""", order_no)
|
""", order_no)
|
||||||
item_count_row = cursor.fetchone()
|
item_count_row = mssql_cursor.fetchone()
|
||||||
item_count = item_count_row[0] if item_count_row else 0
|
item_count = item_count_row[0] if item_count_row else 0
|
||||||
|
|
||||||
|
# SQLite에서 적립 사용자 조회
|
||||||
|
sqlite_cursor.execute("""
|
||||||
|
SELECT u.nickname, u.phone
|
||||||
|
FROM claim_tokens ct
|
||||||
|
LEFT JOIN users u ON ct.claimed_by_user_id = u.id
|
||||||
|
WHERE ct.transaction_id = ? AND ct.claimed_at IS NOT NULL
|
||||||
|
""", (order_no,))
|
||||||
|
claimed_user = sqlite_cursor.fetchone()
|
||||||
|
|
||||||
|
# 적립 사용자 정보 포맷팅
|
||||||
|
if claimed_user and claimed_user['nickname'] and claimed_user['phone']:
|
||||||
|
claimed_info = f"{claimed_user['nickname']} ({claimed_user['phone']})"
|
||||||
|
else:
|
||||||
|
claimed_info = ""
|
||||||
|
|
||||||
sales_list.append({
|
sales_list.append({
|
||||||
'order_no': order_no,
|
'order_no': order_no,
|
||||||
'time': insert_time.strftime('%H:%M') if insert_time else '--:--',
|
'time': insert_time.strftime('%H:%M') if insert_time else '--:--',
|
||||||
'amount': float(sale_amount) if sale_amount else 0.0,
|
'amount': float(sale_amount) if sale_amount else 0.0,
|
||||||
'customer': customer,
|
'customer': customer,
|
||||||
'item_count': item_count
|
'item_count': item_count,
|
||||||
|
'claimed_user': claimed_info
|
||||||
})
|
})
|
||||||
|
|
||||||
self.query_complete.emit(sales_list)
|
self.query_complete.emit(sales_list)
|
||||||
@ -83,8 +110,150 @@ class SalesQueryThread(QThread):
|
|||||||
except Exception as e:
|
except Exception as e:
|
||||||
self.query_error.emit(str(e))
|
self.query_error.emit(str(e))
|
||||||
finally:
|
finally:
|
||||||
if conn:
|
if mssql_conn:
|
||||||
conn.close()
|
mssql_conn.close()
|
||||||
|
if sqlite_conn:
|
||||||
|
sqlite_conn.close()
|
||||||
|
|
||||||
|
|
||||||
|
class QRGeneratorThread(QThread):
|
||||||
|
"""
|
||||||
|
QR 토큰 생성 및 라벨 출력 백그라운드 스레드
|
||||||
|
GUI 블로킹 방지
|
||||||
|
"""
|
||||||
|
qr_complete = pyqtSignal(bool, str, str) # 성공 여부, 메시지, 이미지 경로
|
||||||
|
|
||||||
|
def __init__(self, transaction_id, total_amount, transaction_time, preview_mode=False):
|
||||||
|
"""
|
||||||
|
Args:
|
||||||
|
transaction_id (str): POS 거래 ID
|
||||||
|
total_amount (float): 판매 금액
|
||||||
|
transaction_time (datetime): 거래 시간
|
||||||
|
preview_mode (bool): 미리보기 모드
|
||||||
|
"""
|
||||||
|
super().__init__()
|
||||||
|
self.transaction_id = transaction_id
|
||||||
|
self.total_amount = total_amount
|
||||||
|
self.transaction_time = transaction_time
|
||||||
|
self.preview_mode = preview_mode
|
||||||
|
|
||||||
|
def run(self):
|
||||||
|
"""스레드 실행"""
|
||||||
|
try:
|
||||||
|
# 1. Claim Token 생성
|
||||||
|
token_info = generate_claim_token(
|
||||||
|
self.transaction_id,
|
||||||
|
self.total_amount
|
||||||
|
)
|
||||||
|
|
||||||
|
# 2. DB 저장
|
||||||
|
success, error = save_token_to_db(
|
||||||
|
self.transaction_id,
|
||||||
|
token_info['token_hash'],
|
||||||
|
self.total_amount,
|
||||||
|
token_info['claimable_points'],
|
||||||
|
token_info['expires_at'],
|
||||||
|
token_info['pharmacy_id']
|
||||||
|
)
|
||||||
|
|
||||||
|
if not success:
|
||||||
|
self.qr_complete.emit(False, error, "")
|
||||||
|
return
|
||||||
|
|
||||||
|
# 3. QR 라벨 생성
|
||||||
|
if self.preview_mode:
|
||||||
|
# 미리보기
|
||||||
|
success, image_path = print_qr_label(
|
||||||
|
token_info['qr_url'],
|
||||||
|
self.transaction_id,
|
||||||
|
self.total_amount,
|
||||||
|
token_info['claimable_points'],
|
||||||
|
self.transaction_time,
|
||||||
|
preview_mode=True
|
||||||
|
)
|
||||||
|
|
||||||
|
if success:
|
||||||
|
self.qr_complete.emit(
|
||||||
|
True,
|
||||||
|
f"QR 생성 완료 ({token_info['claimable_points']}P)",
|
||||||
|
image_path
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
self.qr_complete.emit(False, "이미지 생성 실패", "")
|
||||||
|
else:
|
||||||
|
# 실제 인쇄
|
||||||
|
success = print_qr_label(
|
||||||
|
token_info['qr_url'],
|
||||||
|
self.transaction_id,
|
||||||
|
self.total_amount,
|
||||||
|
token_info['claimable_points'],
|
||||||
|
self.transaction_time,
|
||||||
|
preview_mode=False
|
||||||
|
)
|
||||||
|
|
||||||
|
if success:
|
||||||
|
self.qr_complete.emit(
|
||||||
|
True,
|
||||||
|
f"QR 출력 완료 ({token_info['claimable_points']}P)",
|
||||||
|
""
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
self.qr_complete.emit(False, "프린터 전송 실패", "")
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
self.qr_complete.emit(False, f"오류: {str(e)}", "")
|
||||||
|
|
||||||
|
|
||||||
|
class QRLabelPreviewDialog(QDialog):
|
||||||
|
"""
|
||||||
|
QR 라벨 미리보기 팝업 (barcode_reader_gui.py의 LabelPreviewDialog 참고)
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self, image_path, transaction_id, parent=None):
|
||||||
|
"""
|
||||||
|
Args:
|
||||||
|
image_path (str): 미리보기 이미지 파일 경로
|
||||||
|
transaction_id (str): 거래 번호
|
||||||
|
parent: 부모 위젯
|
||||||
|
"""
|
||||||
|
super().__init__(parent)
|
||||||
|
self.image_path = image_path
|
||||||
|
self.transaction_id = transaction_id
|
||||||
|
self.init_ui()
|
||||||
|
|
||||||
|
def init_ui(self):
|
||||||
|
"""UI 초기화"""
|
||||||
|
self.setWindowTitle(f'QR 라벨 미리보기 - {self.transaction_id}')
|
||||||
|
self.setModal(False) # 모달 아님 (계속 작업 가능)
|
||||||
|
|
||||||
|
layout = QVBoxLayout()
|
||||||
|
|
||||||
|
# 안내 라벨
|
||||||
|
info_label = QLabel('[미리보기] 실제 인쇄하려면 "미리보기 모드" 체크를 해제하세요.')
|
||||||
|
info_label.setStyleSheet('color: #2196F3; font-size: 12px; padding: 10px;')
|
||||||
|
layout.addWidget(info_label)
|
||||||
|
|
||||||
|
# 이미지 표시
|
||||||
|
from PyQt5.QtGui import QPixmap
|
||||||
|
pixmap = QPixmap(self.image_path)
|
||||||
|
|
||||||
|
# 스케일링 (최대 1000px 폭)
|
||||||
|
if pixmap.width() > 1000:
|
||||||
|
pixmap = pixmap.scaledToWidth(1000, Qt.SmoothTransformation)
|
||||||
|
|
||||||
|
image_label = QLabel()
|
||||||
|
image_label.setPixmap(pixmap)
|
||||||
|
image_label.setAlignment(Qt.AlignCenter)
|
||||||
|
layout.addWidget(image_label)
|
||||||
|
|
||||||
|
# 닫기 버튼
|
||||||
|
close_btn = QPushButton('닫기')
|
||||||
|
close_btn.setStyleSheet('background-color: #2196F3; color: white; font-weight: bold; padding: 8px 20px;')
|
||||||
|
close_btn.clicked.connect(self.close)
|
||||||
|
layout.addWidget(close_btn)
|
||||||
|
|
||||||
|
self.setLayout(layout)
|
||||||
|
self.adjustSize()
|
||||||
|
|
||||||
|
|
||||||
class SaleDetailDialog(QDialog):
|
class SaleDetailDialog(QDialog):
|
||||||
@ -197,13 +366,14 @@ class POSSalesGUI(QMainWindow):
|
|||||||
super().__init__()
|
super().__init__()
|
||||||
self.db_manager = DatabaseManager()
|
self.db_manager = DatabaseManager()
|
||||||
self.sales_thread = None
|
self.sales_thread = None
|
||||||
|
self.qr_thread = None # QR 생성 스레드 추가
|
||||||
self.sales_data = []
|
self.sales_data = []
|
||||||
self.init_ui()
|
self.init_ui()
|
||||||
|
|
||||||
def init_ui(self):
|
def init_ui(self):
|
||||||
"""UI 초기화"""
|
"""UI 초기화"""
|
||||||
self.setWindowTitle('POS 판매 조회')
|
self.setWindowTitle('POS 판매 조회')
|
||||||
self.setGeometry(100, 100, 900, 600)
|
self.setGeometry(100, 100, 1100, 600)
|
||||||
|
|
||||||
# 중앙 위젯
|
# 중앙 위젯
|
||||||
central_widget = QWidget()
|
central_widget = QWidget()
|
||||||
@ -232,13 +402,21 @@ class POSSalesGUI(QMainWindow):
|
|||||||
self.refresh_btn.clicked.connect(self.refresh_sales)
|
self.refresh_btn.clicked.connect(self.refresh_sales)
|
||||||
settings_layout.addWidget(self.refresh_btn)
|
settings_layout.addWidget(self.refresh_btn)
|
||||||
|
|
||||||
# QR 생성 버튼 (Phase 2 준비 - 현재 비활성화)
|
# QR 생성 버튼 (활성화)
|
||||||
self.qr_btn = QPushButton('QR 생성')
|
self.qr_btn = QPushButton('QR 생성')
|
||||||
self.qr_btn.setEnabled(False)
|
self.qr_btn.setEnabled(True) # 활성화!
|
||||||
self.qr_btn.setStyleSheet('background-color: #9E9E9E; color: white; padding: 8px; font-weight: bold;')
|
self.qr_btn.setStyleSheet('background-color: #FF9800; color: white; padding: 8px; font-weight: bold;')
|
||||||
self.qr_btn.setToolTip('후향적 적립 QR (추후 개발)')
|
self.qr_btn.setToolTip('선택된 거래의 QR 적립 라벨 생성')
|
||||||
|
self.qr_btn.clicked.connect(self.generate_qr_label) # 이벤트 연결
|
||||||
settings_layout.addWidget(self.qr_btn)
|
settings_layout.addWidget(self.qr_btn)
|
||||||
|
|
||||||
|
# 미리보기 모드 체크박스 추가
|
||||||
|
self.preview_checkbox = QCheckBox('미리보기 모드')
|
||||||
|
self.preview_checkbox.setChecked(True) # 기본값: 미리보기
|
||||||
|
self.preview_checkbox.setStyleSheet('font-size: 12px; color: #4CAF50;')
|
||||||
|
self.preview_checkbox.setToolTip('체크: PNG 미리보기, 해제: 프린터 직접 출력')
|
||||||
|
settings_layout.addWidget(self.preview_checkbox)
|
||||||
|
|
||||||
settings_layout.addStretch()
|
settings_layout.addStretch()
|
||||||
|
|
||||||
main_layout.addWidget(settings_group)
|
main_layout.addWidget(settings_group)
|
||||||
@ -249,15 +427,16 @@ class POSSalesGUI(QMainWindow):
|
|||||||
sales_group.setLayout(sales_layout)
|
sales_group.setLayout(sales_layout)
|
||||||
|
|
||||||
self.sales_table = QTableWidget()
|
self.sales_table = QTableWidget()
|
||||||
self.sales_table.setColumnCount(5)
|
self.sales_table.setColumnCount(6)
|
||||||
self.sales_table.setHorizontalHeaderLabels([
|
self.sales_table.setHorizontalHeaderLabels([
|
||||||
'주문번호', '시간', '금액', '고객명', '품목수'
|
'주문번호', '시간', '금액', '고객명', '품목수', '적립 사용자'
|
||||||
])
|
])
|
||||||
self.sales_table.setColumnWidth(0, 180)
|
self.sales_table.setColumnWidth(0, 160)
|
||||||
self.sales_table.setColumnWidth(1, 80)
|
self.sales_table.setColumnWidth(1, 70)
|
||||||
self.sales_table.setColumnWidth(2, 120)
|
self.sales_table.setColumnWidth(2, 110)
|
||||||
self.sales_table.setColumnWidth(3, 120)
|
self.sales_table.setColumnWidth(3, 100)
|
||||||
self.sales_table.setColumnWidth(4, 80)
|
self.sales_table.setColumnWidth(4, 70)
|
||||||
|
self.sales_table.setColumnWidth(5, 180)
|
||||||
self.sales_table.setSelectionBehavior(QTableWidget.SelectRows)
|
self.sales_table.setSelectionBehavior(QTableWidget.SelectRows)
|
||||||
self.sales_table.doubleClicked.connect(self.show_sale_detail)
|
self.sales_table.doubleClicked.connect(self.show_sale_detail)
|
||||||
|
|
||||||
@ -283,6 +462,11 @@ class POSSalesGUI(QMainWindow):
|
|||||||
# 초기 조회 실행
|
# 초기 조회 실행
|
||||||
self.refresh_sales()
|
self.refresh_sales()
|
||||||
|
|
||||||
|
# 자동 새로고침 타이머 (30초마다)
|
||||||
|
self.refresh_timer = QTimer()
|
||||||
|
self.refresh_timer.timeout.connect(self.refresh_sales)
|
||||||
|
self.refresh_timer.start(30000) # 30초 = 30000ms
|
||||||
|
|
||||||
def on_date_changed(self, date):
|
def on_date_changed(self, date):
|
||||||
"""날짜 변경 시 유효성 검사"""
|
"""날짜 변경 시 유효성 검사"""
|
||||||
today = QDate.currentDate()
|
today = QDate.currentDate()
|
||||||
@ -339,7 +523,7 @@ class POSSalesGUI(QMainWindow):
|
|||||||
amount_item.setTextAlignment(Qt.AlignRight | Qt.AlignVCenter)
|
amount_item.setTextAlignment(Qt.AlignRight | Qt.AlignVCenter)
|
||||||
self.sales_table.setItem(row, 2, amount_item)
|
self.sales_table.setItem(row, 2, amount_item)
|
||||||
|
|
||||||
# 고객명
|
# 고객명 (MSSQL)
|
||||||
self.sales_table.setItem(row, 3, QTableWidgetItem(sale['customer']))
|
self.sales_table.setItem(row, 3, QTableWidgetItem(sale['customer']))
|
||||||
|
|
||||||
# 품목수 (중앙 정렬)
|
# 품목수 (중앙 정렬)
|
||||||
@ -347,6 +531,16 @@ class POSSalesGUI(QMainWindow):
|
|||||||
count_item.setTextAlignment(Qt.AlignCenter)
|
count_item.setTextAlignment(Qt.AlignCenter)
|
||||||
self.sales_table.setItem(row, 4, count_item)
|
self.sales_table.setItem(row, 4, count_item)
|
||||||
|
|
||||||
|
# 적립 사용자 (SQLite)
|
||||||
|
claimed_item = QTableWidgetItem(sale['claimed_user'])
|
||||||
|
if sale['claimed_user']:
|
||||||
|
from PyQt5.QtGui import QColor, QFont
|
||||||
|
claimed_item.setForeground(QColor('#4CAF50'))
|
||||||
|
font = QFont()
|
||||||
|
font.setBold(True)
|
||||||
|
claimed_item.setFont(font)
|
||||||
|
self.sales_table.setItem(row, 5, claimed_item)
|
||||||
|
|
||||||
def on_query_error(self, error_msg):
|
def on_query_error(self, error_msg):
|
||||||
"""DB 조회 에러 처리"""
|
"""DB 조회 에러 처리"""
|
||||||
QMessageBox.critical(self, '오류', f'조회 실패:\n{error_msg}')
|
QMessageBox.critical(self, '오류', f'조회 실패:\n{error_msg}')
|
||||||
@ -364,10 +558,89 @@ class POSSalesGUI(QMainWindow):
|
|||||||
detail_dialog = SaleDetailDialog(order_no, self)
|
detail_dialog = SaleDetailDialog(order_no, self)
|
||||||
detail_dialog.exec_()
|
detail_dialog.exec_()
|
||||||
|
|
||||||
|
def generate_qr_label(self):
|
||||||
|
"""선택된 판매 건에 대해 QR 라벨 생성"""
|
||||||
|
# 선택된 행 확인
|
||||||
|
current_row = self.sales_table.currentRow()
|
||||||
|
if current_row < 0:
|
||||||
|
QMessageBox.warning(self, '경고', '거래를 선택해주세요.')
|
||||||
|
return
|
||||||
|
|
||||||
|
# 거래 정보 가져오기
|
||||||
|
order_no = self.sales_table.item(current_row, 0).text()
|
||||||
|
amount_text = self.sales_table.item(current_row, 2).text()
|
||||||
|
|
||||||
|
# 금액 파싱 (예: "50,000원" → 50000.0)
|
||||||
|
amount = float(amount_text.replace(',', '').replace('원', ''))
|
||||||
|
|
||||||
|
# sales_data에서 거래 시간 찾기
|
||||||
|
sale = next((s for s in self.sales_data if s['order_no'] == order_no), None)
|
||||||
|
if not sale:
|
||||||
|
QMessageBox.warning(self, '오류', '거래 정보를 찾을 수 없습니다.')
|
||||||
|
return
|
||||||
|
|
||||||
|
# 거래 시간 파싱
|
||||||
|
date_str = self.date_edit.date().toString('yyyy-MM-dd')
|
||||||
|
time_str = sale['time']
|
||||||
|
transaction_time = datetime.strptime(f"{date_str} {time_str}", '%Y-%m-%d %H:%M')
|
||||||
|
|
||||||
|
# 중복 방지 확인 (이미 QR 생성된 거래인지)
|
||||||
|
if self.qr_thread and self.qr_thread.isRunning():
|
||||||
|
QMessageBox.warning(self, '경고', 'QR 생성 중입니다. 잠시만 기다려주세요.')
|
||||||
|
return
|
||||||
|
|
||||||
|
# 미리보기 모드 확인
|
||||||
|
preview_mode = self.preview_checkbox.isChecked()
|
||||||
|
|
||||||
|
# QR 생성 스레드 시작
|
||||||
|
self.qr_thread = QRGeneratorThread(
|
||||||
|
order_no,
|
||||||
|
amount,
|
||||||
|
transaction_time,
|
||||||
|
preview_mode
|
||||||
|
)
|
||||||
|
self.qr_thread.qr_complete.connect(self.on_qr_generated)
|
||||||
|
self.qr_thread.start()
|
||||||
|
|
||||||
|
# 상태 표시
|
||||||
|
self.status_label.setText(f'QR 생성 중... ({order_no})')
|
||||||
|
self.status_label.setStyleSheet('color: orange; font-size: 12px; padding: 5px;')
|
||||||
|
self.qr_btn.setEnabled(False)
|
||||||
|
|
||||||
|
def on_qr_generated(self, success, message, image_path):
|
||||||
|
"""QR 생성 완료 시그널 핸들러"""
|
||||||
|
# 버튼 재활성화
|
||||||
|
self.qr_btn.setEnabled(True)
|
||||||
|
|
||||||
|
if success:
|
||||||
|
# 성공
|
||||||
|
self.status_label.setText(f'✓ {message}')
|
||||||
|
self.status_label.setStyleSheet('color: green; font-size: 12px; padding: 5px;')
|
||||||
|
|
||||||
|
# 미리보기 모드면 Dialog 표시
|
||||||
|
if image_path:
|
||||||
|
order_no = self.sales_table.item(self.sales_table.currentRow(), 0).text()
|
||||||
|
preview_dialog = QRLabelPreviewDialog(image_path, order_no, self)
|
||||||
|
preview_dialog.show()
|
||||||
|
else:
|
||||||
|
# 실제 인쇄 완료
|
||||||
|
QMessageBox.information(self, '완료', f'{message}\n프린터: 192.168.0.168')
|
||||||
|
else:
|
||||||
|
# 실패
|
||||||
|
self.status_label.setText('QR 생성 실패')
|
||||||
|
self.status_label.setStyleSheet('color: red; font-size: 12px; padding: 5px;')
|
||||||
|
QMessageBox.critical(self, '오류', f'QR 생성 실패:\n{message}')
|
||||||
|
|
||||||
def closeEvent(self, event):
|
def closeEvent(self, event):
|
||||||
"""종료 시 정리"""
|
"""종료 시 정리"""
|
||||||
|
# 자동 새로고침 타이머 중지
|
||||||
|
if hasattr(self, 'refresh_timer'):
|
||||||
|
self.refresh_timer.stop()
|
||||||
|
|
||||||
if self.sales_thread and self.sales_thread.isRunning():
|
if self.sales_thread and self.sales_thread.isRunning():
|
||||||
self.sales_thread.wait()
|
self.sales_thread.wait()
|
||||||
|
if self.qr_thread and self.qr_thread.isRunning(): # QR 스레드 추가
|
||||||
|
self.qr_thread.wait()
|
||||||
self.db_manager.close_all()
|
self.db_manager.close_all()
|
||||||
event.accept()
|
event.accept()
|
||||||
|
|
||||||
|
|||||||
372
backend/samples/barcode_print.py
Normal file
372
backend/samples/barcode_print.py
Normal file
@ -0,0 +1,372 @@
|
|||||||
|
"""
|
||||||
|
바코드 스캔 시 간단한 라벨 자동 출력 모듈
|
||||||
|
Brother QL-810W 프린터용
|
||||||
|
"""
|
||||||
|
|
||||||
|
from PIL import Image, ImageDraw, ImageFont
|
||||||
|
from brother_ql.raster import BrotherQLRaster
|
||||||
|
from brother_ql.conversion import convert
|
||||||
|
from brother_ql.backends.helpers import send
|
||||||
|
import os
|
||||||
|
import logging
|
||||||
|
import re
|
||||||
|
from datetime import datetime
|
||||||
|
import glob
|
||||||
|
|
||||||
|
# 프린터 설정
|
||||||
|
PRINTER_IP = "192.168.0.168"
|
||||||
|
PRINTER_PORT = 9100
|
||||||
|
PRINTER_MODEL = "QL-810W"
|
||||||
|
LABEL_TYPE = "29"
|
||||||
|
|
||||||
|
# 로깅 설정
|
||||||
|
logging.basicConfig(level=logging.INFO, format='[BARCODE_PRINT] %(levelname)s: %(message)s')
|
||||||
|
|
||||||
|
|
||||||
|
def normalize_medication_name(med_name):
|
||||||
|
"""
|
||||||
|
약품명 정제 (print_label.py의 함수 복사)
|
||||||
|
- 괄호 제거
|
||||||
|
- 밀리그램 → mg 변환
|
||||||
|
- 대괄호 제거
|
||||||
|
|
||||||
|
Args:
|
||||||
|
med_name: 약품명
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
str: 정제된 약품명
|
||||||
|
"""
|
||||||
|
if not med_name:
|
||||||
|
return med_name
|
||||||
|
|
||||||
|
# 대괄호 및 내용 제거
|
||||||
|
med_name = re.sub(r'\[.*?\]', '', med_name)
|
||||||
|
med_name = re.sub(r'\[.*$', '', med_name)
|
||||||
|
|
||||||
|
# 소괄호 및 내용 제거
|
||||||
|
med_name = re.sub(r'\(.*?\)', '', med_name)
|
||||||
|
med_name = re.sub(r'\(.*$', '', med_name)
|
||||||
|
|
||||||
|
# 언더스코어 뒤 내용 제거
|
||||||
|
med_name = re.sub(r'_.*$', '', med_name)
|
||||||
|
|
||||||
|
# 밀리그램 변환
|
||||||
|
med_name = re.sub(r'밀리그램|밀리그람|미리그램|미리그람', 'mg', med_name)
|
||||||
|
|
||||||
|
# 마이크로그램 변환
|
||||||
|
med_name = re.sub(r'마이크로그램|마이크로그람', 'μg', med_name)
|
||||||
|
|
||||||
|
# 그램 변환 (단, mg/μg로 이미 변환된 것은 제외)
|
||||||
|
med_name = re.sub(r'(?<!m)(?<!μ)그램|그람', 'g', med_name)
|
||||||
|
|
||||||
|
# 공백 정리
|
||||||
|
med_name = re.sub(r'\s+', ' ', med_name).strip()
|
||||||
|
|
||||||
|
return med_name
|
||||||
|
|
||||||
|
|
||||||
|
def create_wide_label(goods_name, sale_price):
|
||||||
|
"""
|
||||||
|
가로형 와이드 라벨 이미지 생성 (product_label.py 기반)
|
||||||
|
|
||||||
|
- 크기: 800 x 306px (Brother QL 29mm 가로형)
|
||||||
|
- 하드코딩: 효능 "치통/진통제", 용법, 사용팁
|
||||||
|
- 동적: 약품명만 스캔된 값 사용
|
||||||
|
|
||||||
|
Args:
|
||||||
|
goods_name: 약품명 (스캔된 실제 약품명)
|
||||||
|
sale_price: 판매가 (사용하지 않음, 호환성 유지)
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
PIL.Image: 생성된 가로형 라벨 이미지 (800x306px, mode='1')
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
# 1. 캔버스 생성 (가로로 긴 형태)
|
||||||
|
width = 800
|
||||||
|
height = 306 # Brother QL 29mm 용지 폭
|
||||||
|
|
||||||
|
img = Image.new('1', (width, height), 1) # 흰색 배경
|
||||||
|
draw = ImageDraw.Draw(img)
|
||||||
|
|
||||||
|
# 2. 폰트 로드
|
||||||
|
font_path = os.path.join(os.path.dirname(__file__), "fonts", "malgunbd.ttf")
|
||||||
|
try:
|
||||||
|
font_effect = ImageFont.truetype(font_path, 72) # 효능 (매우 크게!)
|
||||||
|
font_drugname = ImageFont.truetype(font_path, 36) # 약품명 (중간)
|
||||||
|
font_dosage = ImageFont.truetype(font_path, 50) # 용법 (크게, 사용팁 없으므로)
|
||||||
|
font_pharmacy = ImageFont.truetype(font_path, 32) # 약국명 (크게)
|
||||||
|
font_small = ImageFont.truetype(font_path, 26) # 사용팁
|
||||||
|
except IOError:
|
||||||
|
logging.warning("폰트 로드 실패. 기본 폰트 사용.")
|
||||||
|
font_effect = ImageFont.load_default()
|
||||||
|
font_drugname = ImageFont.load_default()
|
||||||
|
font_dosage = ImageFont.load_default()
|
||||||
|
font_pharmacy = ImageFont.load_default()
|
||||||
|
font_small = ImageFont.load_default()
|
||||||
|
|
||||||
|
# 3. 하드코딩 데이터
|
||||||
|
effect = "치통/진통제"
|
||||||
|
dosage_instruction = "1캡슐 또는 2캡슐 복용, 1일 최대 5캡슐 [다른 NSAID와 복용시 약사와 상담]"
|
||||||
|
usage_tip = "식후 복용 권장"
|
||||||
|
|
||||||
|
# 4. 약품명 정제
|
||||||
|
goods_name = normalize_medication_name(goods_name)
|
||||||
|
|
||||||
|
# 5. 레이아웃 시작
|
||||||
|
x_margin = 25
|
||||||
|
|
||||||
|
# 효능 - 중앙 상단에 크게 (매우 강조!)
|
||||||
|
effect_bbox = draw.textbbox((0, 0), effect, font=font_effect)
|
||||||
|
effect_width = effect_bbox[2] - effect_bbox[0]
|
||||||
|
effect_x = (width - effect_width) // 2
|
||||||
|
|
||||||
|
# 굵게 표시 (offset)
|
||||||
|
for offset in [(0, 0), (1, 0), (2, 0), (0, 1), (1, 1), (2, 1)]:
|
||||||
|
draw.text((effect_x + offset[0], 20 + offset[1]), effect, font=font_effect, fill=0)
|
||||||
|
|
||||||
|
# 약품명 - "치통/진통제 (약품명)" 형식으로 오른쪽에 표시
|
||||||
|
# 괄호 형식으로 표시
|
||||||
|
drugname_text = f"({goods_name})"
|
||||||
|
|
||||||
|
# 효능 텍스트 끝 위치 계산
|
||||||
|
effect_end_x = effect_x + effect_width + 30 # 효능 끝에서 30px 여백
|
||||||
|
|
||||||
|
# 동적 폰트 크기 조정 (박스 안에 들어오도록)
|
||||||
|
max_drugname_width = width - effect_end_x - 50 # 오른쪽 여백 50px
|
||||||
|
drugname_font_size = 48 # 초기 폰트 크기 (크게 시작)
|
||||||
|
|
||||||
|
while drugname_font_size > 20: # 최소 20pt까지 축소
|
||||||
|
font_drugname_dynamic = ImageFont.truetype(font_path, drugname_font_size)
|
||||||
|
drugname_bbox = draw.textbbox((0, 0), drugname_text, font=font_drugname_dynamic)
|
||||||
|
drugname_width = drugname_bbox[2] - drugname_bbox[0]
|
||||||
|
|
||||||
|
if drugname_width <= max_drugname_width:
|
||||||
|
break
|
||||||
|
drugname_font_size -= 2 # 2pt씩 축소
|
||||||
|
|
||||||
|
# 효능과 같은 Y 위치 (중앙 정렬)
|
||||||
|
drugname_height = drugname_bbox[3] - drugname_bbox[1]
|
||||||
|
drugname_y = 20 + (72 - drugname_height) // 2
|
||||||
|
draw.text((effect_end_x, drugname_y), drugname_text, font=font_drugname_dynamic, fill=0)
|
||||||
|
|
||||||
|
# 용법 - 왼쪽 하단에 크게 표시 (동적 폰트 크기 조정)
|
||||||
|
y = 120 # 효능 아래부터 시작
|
||||||
|
|
||||||
|
if dosage_instruction:
|
||||||
|
# 대괄호로 묶인 부분을 별도 줄로 분리
|
||||||
|
dosage_text = re.sub(r'\s*(\[.*?\])\s*', r'\n\1\n', dosage_instruction)
|
||||||
|
|
||||||
|
# 여러 줄 처리
|
||||||
|
dosage_lines = []
|
||||||
|
|
||||||
|
# 줄바꿈으로 먼저 분리
|
||||||
|
text_parts = dosage_text.split('\n')
|
||||||
|
|
||||||
|
for part in text_parts:
|
||||||
|
part = part.strip()
|
||||||
|
if not part:
|
||||||
|
continue
|
||||||
|
|
||||||
|
# 대괄호로 묶인 부분은 그대로 한 줄로
|
||||||
|
if part.startswith('[') and part.endswith(']'):
|
||||||
|
dosage_lines.append(part)
|
||||||
|
# 일반 텍스트는 그대로 추가 (폰트 크기로 조정)
|
||||||
|
else:
|
||||||
|
dosage_lines.append(part)
|
||||||
|
|
||||||
|
# 동적 폰트 크기 조정 (박스 안에 들어오도록)
|
||||||
|
max_dosage_width = width - x_margin - 50 # 좌우 여백
|
||||||
|
dosage_font_size = 50 # 초기 폰트 크기
|
||||||
|
|
||||||
|
# 가장 긴 줄을 기준으로 폰트 크기 조정
|
||||||
|
longest_line = max(dosage_lines, key=len) if dosage_lines else ""
|
||||||
|
test_line = f"□ {longest_line}"
|
||||||
|
|
||||||
|
while dosage_font_size > 30: # 최소 30pt까지 축소
|
||||||
|
font_dosage_dynamic = ImageFont.truetype(font_path, dosage_font_size)
|
||||||
|
test_bbox = draw.textbbox((0, 0), test_line, font=font_dosage_dynamic)
|
||||||
|
test_width = test_bbox[2] - test_bbox[0]
|
||||||
|
|
||||||
|
if test_width <= max_dosage_width:
|
||||||
|
break
|
||||||
|
dosage_font_size -= 2 # 2pt씩 축소
|
||||||
|
|
||||||
|
# 첫 줄에 체크박스 추가
|
||||||
|
if dosage_lines:
|
||||||
|
first_line = f"□ {dosage_lines[0]}"
|
||||||
|
draw.text((x_margin, y), first_line, font=font_dosage_dynamic, fill=0)
|
||||||
|
|
||||||
|
# 줄 간격 조정 (폰트 크기에 비례)
|
||||||
|
line_spacing = int(dosage_font_size * 1.2)
|
||||||
|
y += line_spacing
|
||||||
|
|
||||||
|
# 나머지 줄
|
||||||
|
for line in dosage_lines[1:]:
|
||||||
|
# 대괄호로 묶인 줄은 들여쓰기 없이
|
||||||
|
indent = 0 if (line.startswith('[') and line.endswith(']')) else 30
|
||||||
|
draw.text((x_margin + indent, y), line, font=font_dosage_dynamic, fill=0)
|
||||||
|
y += line_spacing + 2
|
||||||
|
|
||||||
|
# 사용팁 (체크박스 + 텍스트)
|
||||||
|
if usage_tip and y < height - 60:
|
||||||
|
tip_text = f"□ {usage_tip}"
|
||||||
|
# 길면 축약
|
||||||
|
if len(tip_text) > 55:
|
||||||
|
tip_text = tip_text[:52] + "..."
|
||||||
|
draw.text((x_margin, y), tip_text, font=font_small, fill=0)
|
||||||
|
|
||||||
|
# 약국명 - 오른쪽 하단에 크게 (테두리 박스)
|
||||||
|
sign_text = "청춘약국"
|
||||||
|
sign_bbox = draw.textbbox((0, 0), sign_text, font=font_pharmacy)
|
||||||
|
sign_width = sign_bbox[2] - sign_bbox[0]
|
||||||
|
sign_height = sign_bbox[3] - sign_bbox[1]
|
||||||
|
|
||||||
|
# 패딩 설정
|
||||||
|
sign_padding_lr = 10 # 좌우 패딩
|
||||||
|
sign_padding_top = 5 # 상단 패딩
|
||||||
|
sign_padding_bottom = 10 # 하단 패딩
|
||||||
|
|
||||||
|
sign_x = width - sign_width - x_margin - 10 - sign_padding_lr
|
||||||
|
sign_y = height - 55 # 위치 고정
|
||||||
|
|
||||||
|
# 테두리 박스 그리기
|
||||||
|
box_x1 = sign_x - sign_padding_lr
|
||||||
|
box_y1 = sign_y - sign_padding_top
|
||||||
|
box_x2 = sign_x + sign_width + sign_padding_lr
|
||||||
|
box_y2 = sign_y + sign_height + sign_padding_bottom
|
||||||
|
draw.rectangle([box_x1, box_y1, box_x2, box_y2], outline=0, width=2)
|
||||||
|
|
||||||
|
# 약국명 텍스트 (굵게)
|
||||||
|
for offset in [(0, 0), (1, 0), (0, 1), (1, 1)]:
|
||||||
|
draw.text((sign_x + offset[0], sign_y + offset[1]), sign_text, font=font_pharmacy, fill=0)
|
||||||
|
|
||||||
|
# 테두리 (가위선 스타일)
|
||||||
|
for i in range(3):
|
||||||
|
draw.rectangle([5 + i, 5 + i, width - 5 - i, height - 5 - i], outline=0)
|
||||||
|
|
||||||
|
logging.info(f"가로형 와이드 라벨 이미지 생성 성공: {goods_name}")
|
||||||
|
return img
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logging.error(f"가로형 와이드 라벨 이미지 생성 실패: {e}")
|
||||||
|
raise
|
||||||
|
|
||||||
|
|
||||||
|
def cleanup_old_preview_files(max_files=10):
|
||||||
|
"""
|
||||||
|
임시 미리보기 파일 정리 (최대 개수 초과 시 오래된 파일 삭제)
|
||||||
|
|
||||||
|
Args:
|
||||||
|
max_files: 유지할 최대 파일 개수
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
temp_dir = os.path.join(os.path.dirname(__file__), "temp")
|
||||||
|
if not os.path.exists(temp_dir):
|
||||||
|
return
|
||||||
|
|
||||||
|
# label_preview_*.png 파일 목록 가져오기
|
||||||
|
preview_files = glob.glob(os.path.join(temp_dir, "label_preview_*.png"))
|
||||||
|
|
||||||
|
# 파일 개수가 max_files를 초과하면 오래된 파일 삭제
|
||||||
|
if len(preview_files) > max_files:
|
||||||
|
# 생성 시간 기준으로 정렬 (오래된 순)
|
||||||
|
preview_files.sort(key=os.path.getmtime)
|
||||||
|
|
||||||
|
# 초과된 파일 삭제
|
||||||
|
files_to_delete = preview_files[:len(preview_files) - max_files]
|
||||||
|
for file_path in files_to_delete:
|
||||||
|
try:
|
||||||
|
os.remove(file_path)
|
||||||
|
logging.info(f"오래된 미리보기 파일 삭제: {file_path}")
|
||||||
|
except Exception as e:
|
||||||
|
logging.warning(f"파일 삭제 실패: {file_path} - {e}")
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logging.warning(f"미리보기 파일 정리 실패: {e}")
|
||||||
|
|
||||||
|
|
||||||
|
def print_barcode_label(goods_name, sale_price, preview_mode=False):
|
||||||
|
"""
|
||||||
|
바코드 스캔 시 가로형 와이드 라벨 출력 또는 미리보기
|
||||||
|
|
||||||
|
Args:
|
||||||
|
goods_name: 약품명
|
||||||
|
sale_price: 판매가 (호환성 유지용, 내부 미사용)
|
||||||
|
preview_mode: True = 이미지 파일 경로 반환, False = 프린터 전송
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
preview_mode=True: (성공 여부, 이미지 파일 경로)
|
||||||
|
preview_mode=False: 성공 여부 (bool)
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
logging.info(f"가로형 와이드 라벨 {'미리보기' if preview_mode else '출력'} 시작: {goods_name}")
|
||||||
|
|
||||||
|
# 1. 가로형 라벨 이미지 생성
|
||||||
|
label_image = create_wide_label(goods_name, sale_price)
|
||||||
|
|
||||||
|
# 2. 미리보기 모드: PNG 파일로 저장
|
||||||
|
if preview_mode:
|
||||||
|
# temp 디렉터리 생성
|
||||||
|
temp_dir = os.path.join(os.path.dirname(__file__), "temp")
|
||||||
|
os.makedirs(temp_dir, exist_ok=True)
|
||||||
|
|
||||||
|
# 파일명 생성 (타임스탬프 포함)
|
||||||
|
timestamp = datetime.now().strftime("%Y%m%d_%H%M%S_%f")
|
||||||
|
filename = f"label_preview_{timestamp}.png"
|
||||||
|
file_path = os.path.join(temp_dir, filename)
|
||||||
|
|
||||||
|
# PNG로 저장 (회전하지 않은 가로형 이미지)
|
||||||
|
label_image.save(file_path, "PNG")
|
||||||
|
|
||||||
|
logging.info(f"미리보기 이미지 저장 완료: {file_path}")
|
||||||
|
|
||||||
|
# 오래된 파일 정리
|
||||||
|
cleanup_old_preview_files(max_files=10)
|
||||||
|
|
||||||
|
return True, file_path
|
||||||
|
|
||||||
|
# 3. 실제 인쇄 모드: 프린터로 전송
|
||||||
|
else:
|
||||||
|
# 이미지 90도 회전 (시계 반대방향)
|
||||||
|
# Brother QL은 세로 방향 기준이므로 가로형 이미지를 회전
|
||||||
|
label_image_rotated = label_image.rotate(90, expand=True)
|
||||||
|
|
||||||
|
logging.info(f"이미지 회전 완료: {label_image_rotated.size}")
|
||||||
|
|
||||||
|
# Brother QL Raster 객체 생성
|
||||||
|
qlr = BrotherQLRaster(PRINTER_MODEL)
|
||||||
|
|
||||||
|
# PIL 이미지를 Brother QL 형식으로 변환
|
||||||
|
instructions = convert(
|
||||||
|
qlr=qlr,
|
||||||
|
images=[label_image_rotated],
|
||||||
|
label=LABEL_TYPE,
|
||||||
|
rotate="0", # 이미 회전했으므로 0
|
||||||
|
threshold=70.0, # 흑백 변환 임계값
|
||||||
|
dither=False, # 디더링 비활성화 (선명한 텍스트)
|
||||||
|
compress=False, # 압축 비활성화
|
||||||
|
red=False, # 흑백 전용
|
||||||
|
dpi_600=False,
|
||||||
|
hq=True, # 고품질 모드
|
||||||
|
cut=True # 자동 절단
|
||||||
|
)
|
||||||
|
|
||||||
|
# 프린터로 전송
|
||||||
|
printer_identifier = f"tcp://{PRINTER_IP}:{PRINTER_PORT}"
|
||||||
|
send(instructions, printer_identifier=printer_identifier)
|
||||||
|
|
||||||
|
logging.info(f"가로형 와이드 라벨 출력 성공: {goods_name}")
|
||||||
|
return True
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logging.error(f"가로형 와이드 라벨 {'미리보기' if preview_mode else '출력'} 실패: {e}")
|
||||||
|
if preview_mode:
|
||||||
|
return False, None
|
||||||
|
else:
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
# 테스트 코드
|
||||||
|
test_result = print_barcode_label("타이레놀정500mg", 3000.0)
|
||||||
|
print(f"테스트 결과: {'성공' if test_result else '실패'}")
|
||||||
150
backend/samples/barcode_reader.py
Normal file
150
backend/samples/barcode_reader.py
Normal file
@ -0,0 +1,150 @@
|
|||||||
|
"""
|
||||||
|
허니웰 바코드 리더기 COM3 포트 리딩 프로그램
|
||||||
|
바코드 스캔 시 터미널에 실시간 출력
|
||||||
|
"""
|
||||||
|
|
||||||
|
import serial
|
||||||
|
import sys
|
||||||
|
import io
|
||||||
|
from datetime import datetime
|
||||||
|
|
||||||
|
# Windows cp949 인코딩 문제 해결
|
||||||
|
if sys.platform == 'win32':
|
||||||
|
sys.stdout = io.TextIOWrapper(sys.stdout.buffer, encoding='utf-8')
|
||||||
|
|
||||||
|
def read_barcode_from_com3(port='COM3', baudrate=9600, timeout=1):
|
||||||
|
"""
|
||||||
|
COM3 포트에서 바코드 데이터를 읽어 터미널에 출력
|
||||||
|
|
||||||
|
Args:
|
||||||
|
port: COM 포트 번호 (기본값: COM3)
|
||||||
|
baudrate: 통신 속도 (기본값: 9600, 허니웰 기본값)
|
||||||
|
timeout: 읽기 타임아웃 (초)
|
||||||
|
"""
|
||||||
|
|
||||||
|
print(f'[시작] 바코드 리더기 연결 중...')
|
||||||
|
print(f'포트: {port}')
|
||||||
|
print(f'속도: {baudrate} bps')
|
||||||
|
print('-' * 60)
|
||||||
|
|
||||||
|
try:
|
||||||
|
# 시리얼 포트 열기
|
||||||
|
ser = serial.Serial(
|
||||||
|
port=port,
|
||||||
|
baudrate=baudrate,
|
||||||
|
bytesize=serial.EIGHTBITS,
|
||||||
|
parity=serial.PARITY_NONE,
|
||||||
|
stopbits=serial.STOPBITS_ONE,
|
||||||
|
timeout=timeout
|
||||||
|
)
|
||||||
|
|
||||||
|
print(f'[성공] {port} 포트 연결 완료!')
|
||||||
|
print('[대기] 바코드를 스캔해주세요... (종료: Ctrl+C)')
|
||||||
|
print('=' * 60)
|
||||||
|
print()
|
||||||
|
|
||||||
|
scan_count = 0
|
||||||
|
|
||||||
|
while True:
|
||||||
|
# 시리얼 포트에서 데이터 읽기
|
||||||
|
if ser.in_waiting > 0:
|
||||||
|
# 바코드 데이터 읽기 (개행문자까지)
|
||||||
|
barcode_data = ser.readline()
|
||||||
|
|
||||||
|
# 바이트를 문자열로 디코딩
|
||||||
|
try:
|
||||||
|
barcode_str = barcode_data.decode('utf-8').strip()
|
||||||
|
except UnicodeDecodeError:
|
||||||
|
# UTF-8 실패 시 ASCII로 시도
|
||||||
|
barcode_str = barcode_data.decode('ascii', errors='ignore').strip()
|
||||||
|
|
||||||
|
if barcode_str:
|
||||||
|
scan_count += 1
|
||||||
|
timestamp = datetime.now().strftime('%Y-%m-%d %H:%M:%S')
|
||||||
|
|
||||||
|
print(f'[스캔 #{scan_count}] {timestamp}')
|
||||||
|
print(f'바코드: {barcode_str}')
|
||||||
|
print(f'길이: {len(barcode_str)}자')
|
||||||
|
print(f'원본(HEX): {barcode_data.hex()}')
|
||||||
|
print('-' * 60)
|
||||||
|
|
||||||
|
except serial.SerialException as e:
|
||||||
|
print(f'[오류] 포트 연결 실패: {e}')
|
||||||
|
print()
|
||||||
|
print('가능한 해결 방법:')
|
||||||
|
print(' 1. COM3 포트가 다른 프로그램에서 사용 중인지 확인')
|
||||||
|
print(' 2. 바코드 리더기가 제대로 연결되어 있는지 확인')
|
||||||
|
print(' 3. 장치 관리자에서 포트 번호 확인 (COM3이 맞는지)')
|
||||||
|
print(' 4. USB 케이블을 다시 연결해보기')
|
||||||
|
return 1
|
||||||
|
|
||||||
|
except KeyboardInterrupt:
|
||||||
|
print()
|
||||||
|
print('=' * 60)
|
||||||
|
print(f'[종료] 총 {scan_count}개의 바코드를 스캔했습니다.')
|
||||||
|
print('[완료] 프로그램을 종료합니다.')
|
||||||
|
ser.close()
|
||||||
|
return 0
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
print(f'[오류] 예상치 못한 오류 발생: {e}')
|
||||||
|
return 1
|
||||||
|
|
||||||
|
finally:
|
||||||
|
if 'ser' in locals() and ser.is_open:
|
||||||
|
ser.close()
|
||||||
|
print('[정리] 포트 연결 종료')
|
||||||
|
|
||||||
|
|
||||||
|
def list_available_ports():
|
||||||
|
"""사용 가능한 COM 포트 목록 출력"""
|
||||||
|
import serial.tools.list_ports
|
||||||
|
|
||||||
|
ports = serial.tools.list_ports.comports()
|
||||||
|
|
||||||
|
if not ports:
|
||||||
|
print('[알림] 사용 가능한 COM 포트가 없습니다.')
|
||||||
|
return
|
||||||
|
|
||||||
|
print('[사용 가능한 COM 포트]')
|
||||||
|
print('-' * 60)
|
||||||
|
for port in ports:
|
||||||
|
print(f'포트: {port.device}')
|
||||||
|
print(f' 설명: {port.description}')
|
||||||
|
print(f' 제조사: {port.manufacturer}')
|
||||||
|
print()
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == '__main__':
|
||||||
|
import argparse
|
||||||
|
|
||||||
|
parser = argparse.ArgumentParser(
|
||||||
|
description='허니웰 바코드 리더기 COM 포트 리딩 프로그램'
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
'--port',
|
||||||
|
default='COM3',
|
||||||
|
help='COM 포트 번호 (기본값: COM3)'
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
'--baudrate',
|
||||||
|
type=int,
|
||||||
|
default=9600,
|
||||||
|
help='통신 속도 (기본값: 9600)'
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
'--list-ports',
|
||||||
|
action='store_true',
|
||||||
|
help='사용 가능한 COM 포트 목록 출력'
|
||||||
|
)
|
||||||
|
|
||||||
|
args = parser.parse_args()
|
||||||
|
|
||||||
|
if args.list_ports:
|
||||||
|
list_available_ports()
|
||||||
|
else:
|
||||||
|
exit_code = read_barcode_from_com3(
|
||||||
|
port=args.port,
|
||||||
|
baudrate=args.baudrate
|
||||||
|
)
|
||||||
|
sys.exit(exit_code)
|
||||||
142
backend/samples/barcode_reader_README.md
Normal file
142
backend/samples/barcode_reader_README.md
Normal file
@ -0,0 +1,142 @@
|
|||||||
|
# 허니웰 바코드 리더기 COM 포트 리딩 프로그램
|
||||||
|
|
||||||
|
COM3 포트에 연결된 허니웰 바코드 리더기에서 바코드를 실시간으로 읽어 터미널에 출력하는 Python 프로그램입니다.
|
||||||
|
|
||||||
|
## 필수 라이브러리 설치
|
||||||
|
|
||||||
|
```bash
|
||||||
|
pip install pyserial
|
||||||
|
```
|
||||||
|
|
||||||
|
## 사용 방법
|
||||||
|
|
||||||
|
### 1. 기본 실행 (COM3 포트, 9600 bps)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
python barcode_reader.py
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2. 다른 COM 포트 사용
|
||||||
|
|
||||||
|
```bash
|
||||||
|
python barcode_reader.py --port COM5
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3. 통신 속도 변경
|
||||||
|
|
||||||
|
```bash
|
||||||
|
python barcode_reader.py --baudrate 115200
|
||||||
|
```
|
||||||
|
|
||||||
|
### 4. 사용 가능한 COM 포트 목록 확인
|
||||||
|
|
||||||
|
```bash
|
||||||
|
python barcode_reader.py --list-ports
|
||||||
|
```
|
||||||
|
|
||||||
|
## 출력 예시
|
||||||
|
|
||||||
|
```
|
||||||
|
[시작] 바코드 리더기 연결 중...
|
||||||
|
포트: COM3
|
||||||
|
속도: 9600 bps
|
||||||
|
------------------------------------------------------------
|
||||||
|
[성공] COM3 포트 연결 완료!
|
||||||
|
[대기] 바코드를 스캔해주세요... (종료: Ctrl+C)
|
||||||
|
============================================================
|
||||||
|
|
||||||
|
[스캔 #1] 2026-01-07 15:30:45
|
||||||
|
바코드: 8801234567890
|
||||||
|
길이: 13자
|
||||||
|
원본(HEX): 383830313233343536373839300d0a
|
||||||
|
------------------------------------------------------------
|
||||||
|
[스캔 #2] 2026-01-07 15:30:52
|
||||||
|
바코드: ABC123XYZ
|
||||||
|
길이: 9자
|
||||||
|
원본(HEX): 4142433132335859 5a0d0a
|
||||||
|
------------------------------------------------------------
|
||||||
|
```
|
||||||
|
|
||||||
|
## 프로그램 종료
|
||||||
|
|
||||||
|
- **Ctrl + C** 키를 눌러 프로그램을 종료합니다.
|
||||||
|
- 종료 시 총 스캔한 바코드 개수가 표시됩니다.
|
||||||
|
|
||||||
|
## 트러블슈팅
|
||||||
|
|
||||||
|
### 1. "포트 연결 실패" 오류
|
||||||
|
|
||||||
|
**원인:**
|
||||||
|
- COM3 포트가 다른 프로그램에서 사용 중
|
||||||
|
- 바코드 리더기가 제대로 연결되지 않음
|
||||||
|
- 잘못된 포트 번호
|
||||||
|
|
||||||
|
**해결 방법:**
|
||||||
|
```bash
|
||||||
|
# 1. 사용 가능한 포트 목록 확인
|
||||||
|
python barcode_reader.py --list-ports
|
||||||
|
|
||||||
|
# 2. 올바른 포트 번호로 실행
|
||||||
|
python barcode_reader.py --port COM5
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2. 바코드가 읽히지 않음
|
||||||
|
|
||||||
|
**확인 사항:**
|
||||||
|
- 바코드 리더기의 LED가 켜지는지 확인
|
||||||
|
- 바코드 리더기 설정 확인 (USB-COM 모드인지)
|
||||||
|
- 케이블 연결 상태 확인
|
||||||
|
|
||||||
|
### 3. 글자가 깨져서 나옴
|
||||||
|
|
||||||
|
**원인:**
|
||||||
|
- 잘못된 통신 속도(baudrate) 설정
|
||||||
|
|
||||||
|
**해결 방법:**
|
||||||
|
```bash
|
||||||
|
# 다른 통신 속도 시도
|
||||||
|
python barcode_reader.py --baudrate 115200
|
||||||
|
python barcode_reader.py --baudrate 19200
|
||||||
|
```
|
||||||
|
|
||||||
|
허니웰 바코드 리더기의 일반적인 통신 속도:
|
||||||
|
- 9600 bps (기본값)
|
||||||
|
- 19200 bps
|
||||||
|
- 38400 bps
|
||||||
|
- 115200 bps
|
||||||
|
|
||||||
|
## 허니웰 바코드 리더기 설정
|
||||||
|
|
||||||
|
일부 허니웰 바코드 리더기는 USB-COM 모드로 전환해야 할 수 있습니다.
|
||||||
|
|
||||||
|
### USB-COM 모드 활성화 방법:
|
||||||
|
|
||||||
|
1. 바코드 리더기 매뉴얼에서 "USB Serial Emulation" 설정 바코드 찾기
|
||||||
|
2. 해당 바코드 스캔하여 USB-COM 모드 활성화
|
||||||
|
3. 컴퓨터 재연결 후 장치 관리자에서 COM 포트 확인
|
||||||
|
|
||||||
|
## 장치 관리자에서 COM 포트 확인
|
||||||
|
|
||||||
|
1. `Windows + X` → **장치 관리자** 실행
|
||||||
|
2. **포트(COM & LPT)** 항목 확장
|
||||||
|
3. 바코드 리더기의 COM 포트 번호 확인 (예: COM3, COM5 등)
|
||||||
|
|
||||||
|
## 코드 구조
|
||||||
|
|
||||||
|
```python
|
||||||
|
# 주요 함수
|
||||||
|
read_barcode_from_com3(port, baudrate, timeout)
|
||||||
|
├─ 시리얼 포트 열기
|
||||||
|
├─ 바코드 데이터 실시간 읽기
|
||||||
|
├─ UTF-8/ASCII 디코딩
|
||||||
|
└─ 터미널 출력
|
||||||
|
|
||||||
|
list_available_ports()
|
||||||
|
└─ 사용 가능한 COM 포트 목록 출력
|
||||||
|
```
|
||||||
|
|
||||||
|
## 참고
|
||||||
|
|
||||||
|
- 프로그램은 바코드 스캔 시 자동으로 감지하여 출력합니다.
|
||||||
|
- 각 바코드마다 스캔 번호, 시간, 내용, HEX 값이 표시됩니다.
|
||||||
|
- 바코드 리더기는 일반적으로 스캔 후 개행문자(\r\n)를 전송합니다.
|
||||||
692
backend/samples/barcode_reader_gui.py
Normal file
692
backend/samples/barcode_reader_gui.py
Normal file
@ -0,0 +1,692 @@
|
|||||||
|
"""
|
||||||
|
허니웰 바코드 리더기 GUI 프로그램 (PyQt5)
|
||||||
|
COM3 포트에서 바코드를 실시간으로 읽어 화면에 표시
|
||||||
|
MSSQL DB에서 약품 정보 조회 기능 포함
|
||||||
|
"""
|
||||||
|
|
||||||
|
import sys
|
||||||
|
import serial
|
||||||
|
import serial.tools.list_ports
|
||||||
|
from datetime import datetime
|
||||||
|
from PyQt5.QtWidgets import (
|
||||||
|
QApplication, QMainWindow, QWidget, QVBoxLayout, QHBoxLayout,
|
||||||
|
QPushButton, QTextEdit, QComboBox, QLabel, QGroupBox, QSpinBox,
|
||||||
|
QCheckBox, QDialog
|
||||||
|
)
|
||||||
|
from PyQt5.QtCore import QThread, pyqtSignal, Qt
|
||||||
|
from PyQt5.QtGui import QFont, QTextCursor, QPixmap
|
||||||
|
from sqlalchemy import text
|
||||||
|
|
||||||
|
# MSSQL 데이터베이스 연결
|
||||||
|
sys.path.insert(0, '.')
|
||||||
|
from dbsetup import DatabaseManager
|
||||||
|
|
||||||
|
# 바코드 라벨 출력
|
||||||
|
from barcode_print import print_barcode_label
|
||||||
|
|
||||||
|
|
||||||
|
def parse_gs1_barcode(barcode):
|
||||||
|
"""
|
||||||
|
GS1-128 바코드 파싱
|
||||||
|
|
||||||
|
Args:
|
||||||
|
barcode: 원본 바코드 문자열
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
list: 파싱된 바코드 후보 리스트 (우선순위 순)
|
||||||
|
"""
|
||||||
|
candidates = [barcode] # 원본 바코드를 첫 번째 후보로
|
||||||
|
|
||||||
|
# GS1-128: 01로 시작하는 경우 (GTIN)
|
||||||
|
if barcode.startswith('01') and len(barcode) >= 16:
|
||||||
|
# 01 + 14자리 GTIN
|
||||||
|
gtin14 = barcode[2:16]
|
||||||
|
candidates.append(gtin14)
|
||||||
|
|
||||||
|
# GTIN-14를 GTIN-13으로 변환 (앞자리가 0인 경우)
|
||||||
|
if gtin14.startswith('0'):
|
||||||
|
gtin13 = gtin14[1:]
|
||||||
|
candidates.append(gtin13)
|
||||||
|
|
||||||
|
# GS1-128: 01로 시작하지만 13자리인 경우
|
||||||
|
elif barcode.startswith('01') and len(barcode) == 15:
|
||||||
|
gtin13 = barcode[2:15]
|
||||||
|
candidates.append(gtin13)
|
||||||
|
|
||||||
|
return candidates
|
||||||
|
|
||||||
|
|
||||||
|
def search_drug_by_barcode(barcode):
|
||||||
|
"""
|
||||||
|
바코드로 약품 정보 조회 (MSSQL PM_DRUG.CD_GOODS)
|
||||||
|
GS1-128 바코드 자동 파싱 지원
|
||||||
|
|
||||||
|
Args:
|
||||||
|
barcode: 바코드 번호
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
tuple: (약품 정보 dict 또는 None, 파싱 정보 dict)
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
db_manager = DatabaseManager()
|
||||||
|
engine = db_manager.get_engine('PM_DRUG')
|
||||||
|
|
||||||
|
query = text('''
|
||||||
|
SELECT TOP 1
|
||||||
|
BARCODE,
|
||||||
|
GoodsName,
|
||||||
|
DrugCode,
|
||||||
|
SplName,
|
||||||
|
Price,
|
||||||
|
Saleprice,
|
||||||
|
SUNG_CODE,
|
||||||
|
IsUSE
|
||||||
|
FROM CD_GOODS
|
||||||
|
WHERE BARCODE = :barcode
|
||||||
|
AND (GoodsName NOT LIKE N'%(판매중지)%' AND GoodsName NOT LIKE N'%(판매중단)%')
|
||||||
|
ORDER BY
|
||||||
|
CASE WHEN IsUSE = '1' THEN 0 ELSE 1 END, -- 1. 사용중인 제품 우선
|
||||||
|
CASE WHEN Price > 0 THEN 0 ELSE 1 END, -- 2. 가격 정보 있는 제품 우선
|
||||||
|
CASE WHEN SplName IS NOT NULL AND SplName != '' THEN 0 ELSE 1 END, -- 3. 제조사 정보 있는 제품 우선
|
||||||
|
DrugCode DESC -- 4. 약품코드 내림차순
|
||||||
|
''')
|
||||||
|
|
||||||
|
# GS1 바코드 파싱
|
||||||
|
candidates = parse_gs1_barcode(barcode)
|
||||||
|
parse_info = {
|
||||||
|
'original': barcode,
|
||||||
|
'candidates': candidates,
|
||||||
|
'matched_barcode': None,
|
||||||
|
'is_gs1': len(candidates) > 1
|
||||||
|
}
|
||||||
|
|
||||||
|
with engine.connect() as conn:
|
||||||
|
# 여러 후보 바코드로 순차 검색
|
||||||
|
for candidate in candidates:
|
||||||
|
result = conn.execute(query, {"barcode": candidate})
|
||||||
|
row = result.fetchone()
|
||||||
|
|
||||||
|
if row:
|
||||||
|
parse_info['matched_barcode'] = candidate
|
||||||
|
drug_info = {
|
||||||
|
'barcode': row.BARCODE,
|
||||||
|
'goods_name': row.GoodsName,
|
||||||
|
'drug_code': row.DrugCode,
|
||||||
|
'manufacturer': row.SplName,
|
||||||
|
'price': float(row.Price) if row.Price else 0,
|
||||||
|
'sale_price': float(row.Saleprice) if row.Saleprice else 0,
|
||||||
|
'sung_code': row.SUNG_CODE if row.SUNG_CODE else ''
|
||||||
|
}
|
||||||
|
return drug_info, parse_info
|
||||||
|
|
||||||
|
return None, parse_info
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
print(f'[오류] 약품 조회 실패: {e}')
|
||||||
|
return None, {'original': barcode, 'error': str(e)}
|
||||||
|
|
||||||
|
|
||||||
|
class DrugSearchThread(QThread):
|
||||||
|
"""약품 정보 조회 전용 백그라운드 스레드"""
|
||||||
|
|
||||||
|
# 시그널: (바코드, 타임스탬프, 원본 데이터, 약품 정보, 파싱 정보)
|
||||||
|
search_complete = pyqtSignal(str, str, bytes, object, object)
|
||||||
|
|
||||||
|
def __init__(self, barcode, timestamp, raw_data):
|
||||||
|
super().__init__()
|
||||||
|
self.barcode = barcode
|
||||||
|
self.timestamp = timestamp
|
||||||
|
self.raw_data = raw_data
|
||||||
|
|
||||||
|
def run(self):
|
||||||
|
"""백그라운드에서 DB 조회"""
|
||||||
|
drug_info, parse_info = search_drug_by_barcode(self.barcode)
|
||||||
|
self.search_complete.emit(self.barcode, self.timestamp, self.raw_data, drug_info, parse_info)
|
||||||
|
|
||||||
|
|
||||||
|
class LabelGeneratorThread(QThread):
|
||||||
|
"""라벨 이미지 생성 전용 백그라운드 스레드"""
|
||||||
|
|
||||||
|
# 시그널: (성공 여부, 이미지 경로, 약품명, 에러 메시지)
|
||||||
|
image_ready = pyqtSignal(bool, str, str, str)
|
||||||
|
|
||||||
|
def __init__(self, goods_name, sale_price, preview_mode=False):
|
||||||
|
super().__init__()
|
||||||
|
self.goods_name = goods_name
|
||||||
|
self.sale_price = sale_price
|
||||||
|
self.preview_mode = preview_mode
|
||||||
|
|
||||||
|
def run(self):
|
||||||
|
"""백그라운드에서 이미지 생성"""
|
||||||
|
try:
|
||||||
|
if self.preview_mode:
|
||||||
|
# 미리보기 모드
|
||||||
|
success, image_path = print_barcode_label(
|
||||||
|
self.goods_name,
|
||||||
|
self.sale_price,
|
||||||
|
preview_mode=True
|
||||||
|
)
|
||||||
|
if success:
|
||||||
|
self.image_ready.emit(True, image_path, self.goods_name, "")
|
||||||
|
else:
|
||||||
|
self.image_ready.emit(False, "", self.goods_name, "이미지 생성 실패")
|
||||||
|
else:
|
||||||
|
# 실제 인쇄 모드
|
||||||
|
success = print_barcode_label(
|
||||||
|
self.goods_name,
|
||||||
|
self.sale_price,
|
||||||
|
preview_mode=False
|
||||||
|
)
|
||||||
|
if success:
|
||||||
|
self.image_ready.emit(True, "", self.goods_name, "")
|
||||||
|
else:
|
||||||
|
self.image_ready.emit(False, "", self.goods_name, "라벨 출력 실패")
|
||||||
|
except Exception as e:
|
||||||
|
self.image_ready.emit(False, "", self.goods_name, str(e))
|
||||||
|
|
||||||
|
|
||||||
|
class LabelPreviewDialog(QDialog):
|
||||||
|
"""라벨 미리보기 팝업 창"""
|
||||||
|
|
||||||
|
def __init__(self, image_path, goods_name, parent=None):
|
||||||
|
"""
|
||||||
|
Args:
|
||||||
|
image_path: 미리보기 이미지 파일 경로
|
||||||
|
goods_name: 약품명
|
||||||
|
parent: 부모 위젯
|
||||||
|
"""
|
||||||
|
super().__init__(parent)
|
||||||
|
self.image_path = image_path
|
||||||
|
self.goods_name = goods_name
|
||||||
|
self.init_ui()
|
||||||
|
|
||||||
|
def init_ui(self):
|
||||||
|
"""UI 초기화"""
|
||||||
|
self.setWindowTitle(f'라벨 미리보기 - {self.goods_name}')
|
||||||
|
self.setModal(False) # 모달 아님 (계속 스캔 가능)
|
||||||
|
|
||||||
|
# 레이아웃
|
||||||
|
layout = QVBoxLayout()
|
||||||
|
|
||||||
|
# 상단 안내 라벨
|
||||||
|
info_label = QLabel('[미리보기] 실제 인쇄하려면 "미리보기 모드" 체크를 해제하세요.')
|
||||||
|
info_label.setStyleSheet('color: #2196F3; font-size: 12px; padding: 10px;')
|
||||||
|
layout.addWidget(info_label)
|
||||||
|
|
||||||
|
# 이미지 표시 (QLabel + QPixmap)
|
||||||
|
pixmap = QPixmap(self.image_path)
|
||||||
|
|
||||||
|
# 화면 크기에 맞게 스케일링 (최대 1000px 폭)
|
||||||
|
if pixmap.width() > 1000:
|
||||||
|
pixmap = pixmap.scaledToWidth(1000, Qt.SmoothTransformation)
|
||||||
|
|
||||||
|
image_label = QLabel()
|
||||||
|
image_label.setPixmap(pixmap)
|
||||||
|
image_label.setAlignment(Qt.AlignCenter)
|
||||||
|
layout.addWidget(image_label)
|
||||||
|
|
||||||
|
# 버튼 레이아웃
|
||||||
|
button_layout = QHBoxLayout()
|
||||||
|
|
||||||
|
# 닫기 버튼
|
||||||
|
close_btn = QPushButton('닫기')
|
||||||
|
close_btn.setStyleSheet('background-color: #2196F3; color: white; font-weight: bold; padding: 8px 20px;')
|
||||||
|
close_btn.clicked.connect(self.close)
|
||||||
|
button_layout.addWidget(close_btn)
|
||||||
|
|
||||||
|
layout.addLayout(button_layout)
|
||||||
|
|
||||||
|
self.setLayout(layout)
|
||||||
|
|
||||||
|
# 창 크기 자동 조정
|
||||||
|
self.adjustSize()
|
||||||
|
|
||||||
|
|
||||||
|
class BarcodeReaderThread(QThread):
|
||||||
|
"""바코드 읽기 스레드 (DB 조회 없이 바코드만 읽음)"""
|
||||||
|
barcode_received = pyqtSignal(str, str, bytes) # 바코드, 시간, 원본 (DB 조회 제외!)
|
||||||
|
connection_status = pyqtSignal(bool, str) # 연결 상태, 메시지
|
||||||
|
raw_data_received = pyqtSignal(str) # 시리얼 포트 RAW 데이터 (디버깅용)
|
||||||
|
|
||||||
|
def __init__(self, port='COM3', baudrate=115200):
|
||||||
|
super().__init__()
|
||||||
|
self.port = port
|
||||||
|
self.baudrate = baudrate
|
||||||
|
self.running = False
|
||||||
|
self.serial_connection = None
|
||||||
|
|
||||||
|
def run(self):
|
||||||
|
"""스레드 실행"""
|
||||||
|
self.running = True
|
||||||
|
|
||||||
|
try:
|
||||||
|
# 시리얼 포트 열기
|
||||||
|
self.serial_connection = serial.Serial(
|
||||||
|
port=self.port,
|
||||||
|
baudrate=self.baudrate,
|
||||||
|
bytesize=serial.EIGHTBITS,
|
||||||
|
parity=serial.PARITY_NONE,
|
||||||
|
stopbits=serial.STOPBITS_ONE,
|
||||||
|
timeout=1
|
||||||
|
)
|
||||||
|
|
||||||
|
self.connection_status.emit(True, f'{self.port} 연결 성공! (속도: {self.baudrate} bps)')
|
||||||
|
|
||||||
|
# 바코드 읽기 루프
|
||||||
|
while self.running:
|
||||||
|
if self.serial_connection.in_waiting > 0:
|
||||||
|
buffer_size = self.serial_connection.in_waiting
|
||||||
|
timestamp_ms = datetime.now().strftime('%H:%M:%S.%f')[:-3]
|
||||||
|
|
||||||
|
# 즉시 GUI에 표시
|
||||||
|
self.raw_data_received.emit(f'[{timestamp_ms}] 버퍼: {buffer_size} bytes')
|
||||||
|
|
||||||
|
# 버퍼의 모든 데이터를 한 번에 읽기 (연속 스캔 대응)
|
||||||
|
all_data = self.serial_connection.read(buffer_size)
|
||||||
|
|
||||||
|
# 즉시 GUI에 표시
|
||||||
|
self.raw_data_received.emit(f' → 읽음: {all_data.hex()} ({len(all_data)} bytes)')
|
||||||
|
|
||||||
|
# 디코딩
|
||||||
|
try:
|
||||||
|
all_text = all_data.decode('utf-8')
|
||||||
|
except UnicodeDecodeError:
|
||||||
|
all_text = all_data.decode('ascii', errors='ignore')
|
||||||
|
|
||||||
|
# 개행문자로 분리 (여러 바코드가 함께 들어온 경우)
|
||||||
|
lines = all_text.strip().split('\n')
|
||||||
|
self.raw_data_received.emit(f' → 분리된 라인 수: {len(lines)}')
|
||||||
|
|
||||||
|
for line in lines:
|
||||||
|
barcode_str = line.strip()
|
||||||
|
|
||||||
|
if not barcode_str:
|
||||||
|
continue
|
||||||
|
|
||||||
|
# 즉시 GUI에 표시
|
||||||
|
self.raw_data_received.emit(f' → 처리: "{barcode_str}" (길이: {len(barcode_str)})')
|
||||||
|
|
||||||
|
# 바코드 길이 검증 (13자리 EAN-13, 16자리 GS1-128만 허용)
|
||||||
|
valid_lengths = [13, 15, 16] # EAN-13, GS1-128 (01+13), GS1-128 (01+14)
|
||||||
|
|
||||||
|
if len(barcode_str) not in valid_lengths:
|
||||||
|
# 비정상 길이: 무시
|
||||||
|
self.raw_data_received.emit(f' → [무시] 비정상 길이 {len(barcode_str)}')
|
||||||
|
continue
|
||||||
|
|
||||||
|
timestamp = datetime.now().strftime('%Y-%m-%d %H:%M:%S')
|
||||||
|
|
||||||
|
# 바코드 데이터만 메인 스레드로 전달
|
||||||
|
self.raw_data_received.emit(f' → [OK] 시그널 전송!')
|
||||||
|
self.barcode_received.emit(barcode_str, timestamp, barcode_str.encode('utf-8'))
|
||||||
|
|
||||||
|
# 처리 완료 후 버퍼 확인
|
||||||
|
remaining = self.serial_connection.in_waiting
|
||||||
|
if remaining > 0:
|
||||||
|
self.raw_data_received.emit(f' → [주의] 처리 완료 후 버퍼에 {remaining} bytes 남음 (다음 루프에서 처리)')
|
||||||
|
|
||||||
|
except serial.SerialException as e:
|
||||||
|
self.connection_status.emit(False, f'포트 연결 실패: {str(e)}')
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
self.connection_status.emit(False, f'오류 발생: {str(e)}')
|
||||||
|
|
||||||
|
finally:
|
||||||
|
if self.serial_connection and self.serial_connection.is_open:
|
||||||
|
self.serial_connection.close()
|
||||||
|
|
||||||
|
def stop(self):
|
||||||
|
"""스레드 중지"""
|
||||||
|
self.running = False
|
||||||
|
if self.serial_connection and self.serial_connection.is_open:
|
||||||
|
self.serial_connection.close()
|
||||||
|
|
||||||
|
|
||||||
|
class BarcodeReaderGUI(QMainWindow):
|
||||||
|
"""바코드 리더 GUI 메인 윈도우"""
|
||||||
|
|
||||||
|
def __init__(self):
|
||||||
|
super().__init__()
|
||||||
|
self.reader_thread = None
|
||||||
|
self.scan_count = 0
|
||||||
|
self.search_threads = [] # 약품 조회 스레드 목록
|
||||||
|
self.generator_threads = [] # 라벨 생성 스레드 목록
|
||||||
|
self.init_ui()
|
||||||
|
|
||||||
|
def init_ui(self):
|
||||||
|
"""UI 초기화"""
|
||||||
|
self.setWindowTitle('허니웰 바코드 리더 - COM 포트')
|
||||||
|
self.setGeometry(100, 100, 900, 700)
|
||||||
|
|
||||||
|
# 중앙 위젯
|
||||||
|
central_widget = QWidget()
|
||||||
|
self.setCentralWidget(central_widget)
|
||||||
|
|
||||||
|
# 메인 레이아웃
|
||||||
|
main_layout = QVBoxLayout()
|
||||||
|
central_widget.setLayout(main_layout)
|
||||||
|
|
||||||
|
# === 1. 설정 그룹 ===
|
||||||
|
settings_group = QGroupBox('연결 설정')
|
||||||
|
settings_layout = QHBoxLayout()
|
||||||
|
settings_group.setLayout(settings_layout)
|
||||||
|
|
||||||
|
# COM 포트 선택
|
||||||
|
settings_layout.addWidget(QLabel('COM 포트:'))
|
||||||
|
self.port_combo = QComboBox()
|
||||||
|
self.refresh_ports()
|
||||||
|
settings_layout.addWidget(self.port_combo)
|
||||||
|
|
||||||
|
# 새로고침 버튼
|
||||||
|
refresh_btn = QPushButton('새로고침')
|
||||||
|
refresh_btn.clicked.connect(self.refresh_ports)
|
||||||
|
settings_layout.addWidget(refresh_btn)
|
||||||
|
|
||||||
|
# 통신 속도
|
||||||
|
settings_layout.addWidget(QLabel('속도 (bps):'))
|
||||||
|
self.baudrate_spin = QSpinBox()
|
||||||
|
self.baudrate_spin.setMinimum(9600)
|
||||||
|
self.baudrate_spin.setMaximum(921600)
|
||||||
|
self.baudrate_spin.setValue(115200)
|
||||||
|
self.baudrate_spin.setSingleStep(9600)
|
||||||
|
settings_layout.addWidget(self.baudrate_spin)
|
||||||
|
|
||||||
|
# 수직 구분선
|
||||||
|
settings_layout.addWidget(QLabel('|'))
|
||||||
|
|
||||||
|
# 미리보기 모드 토글
|
||||||
|
self.preview_mode_checkbox = QCheckBox('미리보기 모드 (인쇄 안 함)')
|
||||||
|
self.preview_mode_checkbox.setChecked(True) # 기본값: 미리보기 (종이 절약!)
|
||||||
|
self.preview_mode_checkbox.setStyleSheet('font-size: 14px; color: #4CAF50; font-weight: bold;')
|
||||||
|
settings_layout.addWidget(self.preview_mode_checkbox)
|
||||||
|
|
||||||
|
settings_layout.addStretch()
|
||||||
|
|
||||||
|
main_layout.addWidget(settings_group)
|
||||||
|
|
||||||
|
# === 2. 제어 버튼 ===
|
||||||
|
control_layout = QHBoxLayout()
|
||||||
|
|
||||||
|
self.start_btn = QPushButton('시작')
|
||||||
|
self.start_btn.setStyleSheet('background-color: #4CAF50; color: white; font-weight: bold; padding: 10px;')
|
||||||
|
self.start_btn.clicked.connect(self.start_reading)
|
||||||
|
control_layout.addWidget(self.start_btn)
|
||||||
|
|
||||||
|
self.stop_btn = QPushButton('중지')
|
||||||
|
self.stop_btn.setStyleSheet('background-color: #f44336; color: white; font-weight: bold; padding: 10px;')
|
||||||
|
self.stop_btn.setEnabled(False)
|
||||||
|
self.stop_btn.clicked.connect(self.stop_reading)
|
||||||
|
control_layout.addWidget(self.stop_btn)
|
||||||
|
|
||||||
|
self.clear_btn = QPushButton('화면 지우기')
|
||||||
|
self.clear_btn.setStyleSheet('background-color: #2196F3; color: white; font-weight: bold; padding: 10px;')
|
||||||
|
self.clear_btn.clicked.connect(self.clear_output)
|
||||||
|
control_layout.addWidget(self.clear_btn)
|
||||||
|
|
||||||
|
main_layout.addLayout(control_layout)
|
||||||
|
|
||||||
|
# === 3. 상태 표시 ===
|
||||||
|
status_group = QGroupBox('상태')
|
||||||
|
status_layout = QVBoxLayout()
|
||||||
|
status_group.setLayout(status_layout)
|
||||||
|
|
||||||
|
self.status_label = QLabel('대기 중...')
|
||||||
|
self.status_label.setStyleSheet('color: gray; font-size: 14px; padding: 5px;')
|
||||||
|
status_layout.addWidget(self.status_label)
|
||||||
|
|
||||||
|
self.scan_count_label = QLabel('스캔 횟수: 0')
|
||||||
|
self.scan_count_label.setStyleSheet('color: blue; font-size: 14px; font-weight: bold; padding: 5px;')
|
||||||
|
status_layout.addWidget(self.scan_count_label)
|
||||||
|
|
||||||
|
main_layout.addWidget(status_group)
|
||||||
|
|
||||||
|
# === 4. 바코드 출력 영역 ===
|
||||||
|
output_group = QGroupBox('바코드 스캔 결과')
|
||||||
|
output_layout = QVBoxLayout()
|
||||||
|
output_group.setLayout(output_layout)
|
||||||
|
|
||||||
|
self.output_text = QTextEdit()
|
||||||
|
self.output_text.setReadOnly(True)
|
||||||
|
self.output_text.setFont(QFont('Consolas', 10))
|
||||||
|
self.output_text.setStyleSheet('background-color: #f5f5f5;')
|
||||||
|
output_layout.addWidget(self.output_text)
|
||||||
|
|
||||||
|
main_layout.addWidget(output_group)
|
||||||
|
|
||||||
|
def refresh_ports(self):
|
||||||
|
"""사용 가능한 COM 포트 새로고침"""
|
||||||
|
self.port_combo.clear()
|
||||||
|
ports = serial.tools.list_ports.comports()
|
||||||
|
|
||||||
|
for port in ports:
|
||||||
|
self.port_combo.addItem(f'{port.device} - {port.description}', port.device)
|
||||||
|
|
||||||
|
# COM3이 있으면 선택
|
||||||
|
for i in range(self.port_combo.count()):
|
||||||
|
if 'COM3' in self.port_combo.itemData(i):
|
||||||
|
self.port_combo.setCurrentIndex(i)
|
||||||
|
break
|
||||||
|
|
||||||
|
def start_reading(self):
|
||||||
|
"""바코드 읽기 시작"""
|
||||||
|
if self.reader_thread and self.reader_thread.isRunning():
|
||||||
|
return
|
||||||
|
|
||||||
|
# 선택된 포트와 속도 가져오기
|
||||||
|
port = self.port_combo.currentData()
|
||||||
|
if not port:
|
||||||
|
self.append_output('[오류] COM 포트를 선택해주세요.')
|
||||||
|
return
|
||||||
|
|
||||||
|
baudrate = self.baudrate_spin.value()
|
||||||
|
|
||||||
|
# 스레드 시작
|
||||||
|
self.reader_thread = BarcodeReaderThread(port, baudrate)
|
||||||
|
self.reader_thread.barcode_received.connect(self.on_barcode_received)
|
||||||
|
self.reader_thread.connection_status.connect(self.on_connection_status)
|
||||||
|
self.reader_thread.raw_data_received.connect(self.on_raw_data) # RAW 데이터 표시
|
||||||
|
self.reader_thread.start()
|
||||||
|
|
||||||
|
# UI 업데이트
|
||||||
|
self.start_btn.setEnabled(False)
|
||||||
|
self.stop_btn.setEnabled(True)
|
||||||
|
self.port_combo.setEnabled(False)
|
||||||
|
self.baudrate_spin.setEnabled(False)
|
||||||
|
|
||||||
|
self.status_label.setText(f'연결 시도 중... ({port}, {baudrate} bps)')
|
||||||
|
self.status_label.setStyleSheet('color: orange; font-size: 14px; padding: 5px;')
|
||||||
|
|
||||||
|
def stop_reading(self):
|
||||||
|
"""바코드 읽기 중지"""
|
||||||
|
if self.reader_thread:
|
||||||
|
self.reader_thread.stop()
|
||||||
|
self.reader_thread.wait()
|
||||||
|
|
||||||
|
# UI 업데이트
|
||||||
|
self.start_btn.setEnabled(True)
|
||||||
|
self.stop_btn.setEnabled(False)
|
||||||
|
self.port_combo.setEnabled(True)
|
||||||
|
self.baudrate_spin.setEnabled(True)
|
||||||
|
|
||||||
|
self.status_label.setText('중지됨')
|
||||||
|
self.status_label.setStyleSheet('color: gray; font-size: 14px; padding: 5px;')
|
||||||
|
|
||||||
|
self.append_output('[시스템] 바코드 리더를 중지했습니다.\n')
|
||||||
|
|
||||||
|
def on_connection_status(self, success, message):
|
||||||
|
"""연결 상태 업데이트"""
|
||||||
|
if success:
|
||||||
|
self.status_label.setText(f'연결됨: {message}')
|
||||||
|
self.status_label.setStyleSheet('color: green; font-size: 14px; font-weight: bold; padding: 5px;')
|
||||||
|
self.append_output(f'[시스템] {message}\n')
|
||||||
|
self.append_output('[대기] 바코드를 스캔해주세요...\n')
|
||||||
|
else:
|
||||||
|
self.status_label.setText(f'오류: {message}')
|
||||||
|
self.status_label.setStyleSheet('color: red; font-size: 14px; font-weight: bold; padding: 5px;')
|
||||||
|
self.append_output(f'[오류] {message}\n')
|
||||||
|
self.stop_reading()
|
||||||
|
|
||||||
|
def on_raw_data(self, log_message):
|
||||||
|
"""시리얼 포트 RAW 데이터 즉시 표시 (디버깅용)"""
|
||||||
|
self.append_output(log_message + '\n')
|
||||||
|
|
||||||
|
def on_barcode_received(self, barcode, timestamp, raw_data):
|
||||||
|
"""바코드 수신 처리 (DB 조회는 백그라운드 스레드로)"""
|
||||||
|
self.scan_count += 1
|
||||||
|
self.scan_count_label.setText(f'스캔 횟수: {self.scan_count}')
|
||||||
|
|
||||||
|
# 즉시 로그 출력 (DB 조회 전)
|
||||||
|
output = f'{"=" * 80}\n'
|
||||||
|
output += f'[스캔 #{self.scan_count}] {timestamp}\n'
|
||||||
|
output += f'바코드: {barcode}\n'
|
||||||
|
output += f'길이: {len(barcode)}자\n'
|
||||||
|
output += f'[조회 중...] 약품 정보 검색 중\n'
|
||||||
|
self.append_output(output)
|
||||||
|
|
||||||
|
# 백그라운드 스레드로 DB 조회 작업 위임
|
||||||
|
search_thread = DrugSearchThread(barcode, timestamp, raw_data)
|
||||||
|
search_thread.search_complete.connect(self.on_search_complete)
|
||||||
|
search_thread.start()
|
||||||
|
self.search_threads.append(search_thread)
|
||||||
|
|
||||||
|
def on_search_complete(self, barcode, timestamp, raw_data, drug_info, parse_info):
|
||||||
|
"""약품 조회 완료 시그널 핸들러 (백그라운드 스레드에서 호출)"""
|
||||||
|
# 출력
|
||||||
|
output = ''
|
||||||
|
|
||||||
|
# GS1 파싱 정보 출력
|
||||||
|
if parse_info and parse_info.get('is_gs1'):
|
||||||
|
output += f'[GS1-128 바코드 감지]\n'
|
||||||
|
output += f' 원본 바코드: {parse_info["original"]}\n'
|
||||||
|
if parse_info.get('matched_barcode'):
|
||||||
|
output += f' 매칭된 바코드: {parse_info["matched_barcode"]}\n'
|
||||||
|
if len(parse_info.get('candidates', [])) > 1:
|
||||||
|
output += f' 검색 시도: {", ".join(parse_info["candidates"])}\n'
|
||||||
|
output += '\n'
|
||||||
|
|
||||||
|
# 약품 정보 출력
|
||||||
|
if drug_info:
|
||||||
|
output += f'[약품 정보]\n'
|
||||||
|
output += f' 약품명: {drug_info["goods_name"]}\n'
|
||||||
|
output += f' 약품코드: {drug_info["drug_code"]}\n'
|
||||||
|
output += f' 제조사: {drug_info["manufacturer"]}\n'
|
||||||
|
output += f' 매입가: {drug_info["price"]:,.0f}원\n'
|
||||||
|
output += f' 판매가: {drug_info["sale_price"]:,.0f}원\n'
|
||||||
|
if drug_info["sung_code"]:
|
||||||
|
output += f' 성분코드: {drug_info["sung_code"]}\n'
|
||||||
|
|
||||||
|
# 라벨 출력 또는 미리보기 (백그라운드 스레드)
|
||||||
|
try:
|
||||||
|
is_preview = self.preview_mode_checkbox.isChecked()
|
||||||
|
|
||||||
|
# 백그라운드 스레드로 이미지 생성 작업 위임
|
||||||
|
generator_thread = LabelGeneratorThread(
|
||||||
|
drug_info["goods_name"],
|
||||||
|
drug_info["sale_price"],
|
||||||
|
preview_mode=is_preview
|
||||||
|
)
|
||||||
|
|
||||||
|
# 완료 시그널 연결
|
||||||
|
generator_thread.image_ready.connect(self.on_label_generated)
|
||||||
|
|
||||||
|
# 스레드 시작 및 목록에 추가
|
||||||
|
generator_thread.start()
|
||||||
|
self.generator_threads.append(generator_thread)
|
||||||
|
|
||||||
|
# 로그 출력
|
||||||
|
if is_preview:
|
||||||
|
output += f'\n[미리보기] 이미지 생성 중...\n'
|
||||||
|
else:
|
||||||
|
output += f'\n[출력] 라벨 출력 중...\n'
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
output += f'\n[출력 오류] {str(e)}\n'
|
||||||
|
else:
|
||||||
|
output += f'[약품 정보] 데이터베이스에서 찾을 수 없습니다.\n'
|
||||||
|
|
||||||
|
output += f'\n원본(HEX): {raw_data.hex()}\n'
|
||||||
|
output += f'{"-" * 80}\n\n'
|
||||||
|
|
||||||
|
self.append_output(output)
|
||||||
|
|
||||||
|
# 완료된 스레드 정리
|
||||||
|
sender_thread = self.sender()
|
||||||
|
if sender_thread in self.search_threads:
|
||||||
|
self.search_threads.remove(sender_thread)
|
||||||
|
|
||||||
|
def on_label_generated(self, success, image_path, goods_name, error_msg):
|
||||||
|
"""
|
||||||
|
라벨 생성 완료 시그널 핸들러 (백그라운드 스레드에서 호출)
|
||||||
|
|
||||||
|
Args:
|
||||||
|
success: 성공 여부
|
||||||
|
image_path: 미리보기 이미지 경로 (미리보기 모드일 때만)
|
||||||
|
goods_name: 약품명
|
||||||
|
error_msg: 에러 메시지 (실패 시)
|
||||||
|
"""
|
||||||
|
if success:
|
||||||
|
if image_path:
|
||||||
|
# 미리보기 모드: Dialog 표시
|
||||||
|
self.append_output(f'[미리보기 완료] {goods_name}\n')
|
||||||
|
preview_dialog = LabelPreviewDialog(image_path, goods_name, self)
|
||||||
|
preview_dialog.show()
|
||||||
|
else:
|
||||||
|
# 실제 인쇄 모드: 성공 로그
|
||||||
|
self.append_output(f'[출력 완료] {goods_name} (192.168.0.168)\n')
|
||||||
|
else:
|
||||||
|
# 실패
|
||||||
|
self.append_output(f'[오류] {goods_name}: {error_msg}\n')
|
||||||
|
|
||||||
|
# 완료된 스레드 정리
|
||||||
|
sender_thread = self.sender()
|
||||||
|
if sender_thread in self.generator_threads:
|
||||||
|
self.generator_threads.remove(sender_thread)
|
||||||
|
|
||||||
|
def append_output(self, text):
|
||||||
|
"""출력 영역에 텍스트 추가"""
|
||||||
|
self.output_text.append(text)
|
||||||
|
# 스크롤을 맨 아래로
|
||||||
|
self.output_text.moveCursor(QTextCursor.End)
|
||||||
|
|
||||||
|
def clear_output(self):
|
||||||
|
"""출력 화면 지우기"""
|
||||||
|
self.output_text.clear()
|
||||||
|
self.scan_count = 0
|
||||||
|
self.scan_count_label.setText('스캔 횟수: 0')
|
||||||
|
|
||||||
|
def closeEvent(self, event):
|
||||||
|
"""프로그램 종료 시 스레드 정리"""
|
||||||
|
# 바코드 리더 스레드 종료
|
||||||
|
if self.reader_thread:
|
||||||
|
self.reader_thread.stop()
|
||||||
|
self.reader_thread.wait()
|
||||||
|
|
||||||
|
# 활성 약품 조회 스레드 종료
|
||||||
|
for thread in self.search_threads:
|
||||||
|
if thread.isRunning():
|
||||||
|
thread.wait()
|
||||||
|
|
||||||
|
# 활성 라벨 생성 스레드 종료
|
||||||
|
for thread in self.generator_threads:
|
||||||
|
if thread.isRunning():
|
||||||
|
thread.wait()
|
||||||
|
|
||||||
|
event.accept()
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
"""메인 함수"""
|
||||||
|
app = QApplication(sys.argv)
|
||||||
|
|
||||||
|
# 애플리케이션 스타일
|
||||||
|
app.setStyle('Fusion')
|
||||||
|
|
||||||
|
# 메인 윈도우 생성 및 표시
|
||||||
|
window = BarcodeReaderGUI()
|
||||||
|
window.show()
|
||||||
|
|
||||||
|
sys.exit(app.exec_())
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == '__main__':
|
||||||
|
main()
|
||||||
957
backend/samples/print_label.py
Normal file
957
backend/samples/print_label.py
Normal file
@ -0,0 +1,957 @@
|
|||||||
|
# print_label.py
|
||||||
|
|
||||||
|
from PIL import Image, ImageDraw, ImageFont
|
||||||
|
import io
|
||||||
|
import os
|
||||||
|
import logging
|
||||||
|
from brother_ql.raster import BrotherQLRaster
|
||||||
|
from brother_ql.conversion import convert
|
||||||
|
from brother_ql.backends.helpers import send
|
||||||
|
import datetime # 날짜 처리를 위해 추가
|
||||||
|
import pytz
|
||||||
|
import re
|
||||||
|
import qrcode
|
||||||
|
import json
|
||||||
|
|
||||||
|
# 프린터 기본 설정 (프린터 정보가 전달되지 않을 때 사용)
|
||||||
|
DEFAULT_PRINTER_IP = "192.168.0.121" # QL-710W 프린터의 IP 주소
|
||||||
|
DEFAULT_PRINTER_MODEL = "QL-710W"
|
||||||
|
DEFAULT_LABEL_TYPE = "29" # 29mm 연속 출력 용지
|
||||||
|
|
||||||
|
# 로깅 설정
|
||||||
|
logging.basicConfig(level=logging.DEBUG, format='%(levelname)s:%(message)s')
|
||||||
|
|
||||||
|
# KOR 시간대 설정
|
||||||
|
KOR_TZ = pytz.timezone('Asia/Seoul')
|
||||||
|
|
||||||
|
|
||||||
|
def format_total_amount(dosage, frequency, duration):
|
||||||
|
"""
|
||||||
|
1회 복용량, 복용 횟수, 총 복용 일수를 기반으로 총량을 계산하고,
|
||||||
|
1/4 단위로 반올림하거나 그대로 반환합니다.
|
||||||
|
|
||||||
|
Parameters:
|
||||||
|
dosage (float): 1회 복용량 (소수 넷째 자리까지 포함 가능)
|
||||||
|
frequency (int): 1일 복용 횟수
|
||||||
|
duration (int): 복용 일수
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
str: 포맷팅된 총량 문자열
|
||||||
|
"""
|
||||||
|
if frequency > 0 and duration > 0:
|
||||||
|
# 1일 복용량 = 1회 복용량 * 1일 복용 횟수
|
||||||
|
daily_dosage = dosage * frequency
|
||||||
|
# 총량 = 1일 복용량 * 총 복용 일수
|
||||||
|
total_amount = daily_dosage * duration
|
||||||
|
|
||||||
|
# 1회 복용량이 소수 넷째 자리까지 있는 경우 1/4 단위로 반올림
|
||||||
|
if round(dosage, 4) != round(dosage, 3): # 소수 넷째 자리 여부 확인
|
||||||
|
total_amount = round(total_amount * 4) / 4
|
||||||
|
|
||||||
|
# 정수인 경우 소수점 없이 표시, 소수가 있는 경우 둘째 자리까지 표시
|
||||||
|
return str(int(total_amount)) if total_amount.is_integer() else f"{total_amount:.2f}".rstrip('0').rstrip('.')
|
||||||
|
return "0" # 복용 횟수나 복용 일수가 0인 경우
|
||||||
|
|
||||||
|
|
||||||
|
def format_dosage(dosage):
|
||||||
|
"""
|
||||||
|
1회 복용량을 포맷팅합니다.
|
||||||
|
|
||||||
|
Parameters:
|
||||||
|
dosage (float): 1회 복용량
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
str: 포맷팅된 복용량 문자열
|
||||||
|
"""
|
||||||
|
if dosage.is_integer():
|
||||||
|
return str(int(dosage))
|
||||||
|
else:
|
||||||
|
# 최대 4자리 소수까지 표시, 불필요한 0 제거
|
||||||
|
return f"{dosage:.4f}".rstrip('0').rstrip('.')
|
||||||
|
|
||||||
|
|
||||||
|
def format_converted_total(dosage, frequency, duration, conversion_factor):
|
||||||
|
"""
|
||||||
|
총량을 계산하고 환산계수를 곱한 변환된 총량을 포맷팅합니다.
|
||||||
|
|
||||||
|
Parameters:
|
||||||
|
dosage (float): 1회 복용량 (소수 넷째 자리까지 포함 가능)
|
||||||
|
frequency (int): 1일 복용 횟수
|
||||||
|
duration (int): 복용 일수
|
||||||
|
conversion_factor (float): 환산계수
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
str: 변환된 총량을 포함한 포맷팅된 문자열
|
||||||
|
"""
|
||||||
|
if frequency > 0 and duration > 0 and conversion_factor is not None:
|
||||||
|
total_amount = dosage * frequency * duration
|
||||||
|
if round(dosage, 4) != round(dosage, 3):
|
||||||
|
total_amount = round(total_amount * 4) / 4
|
||||||
|
converted_total = total_amount * conversion_factor
|
||||||
|
if converted_total.is_integer():
|
||||||
|
return str(int(converted_total))
|
||||||
|
else:
|
||||||
|
return f"{converted_total:.2f}".rstrip('0').rstrip('.')
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def draw_scissor_border(draw, width, height, edge_size=5, steps=230):
|
||||||
|
"""
|
||||||
|
라벨 이미지의 테두리에 톱니 모양의 절취선을 그립니다.
|
||||||
|
|
||||||
|
Parameters:
|
||||||
|
draw (ImageDraw.Draw): 이미지에 그리기 위한 Draw 객체
|
||||||
|
width (int): 라벨 너비
|
||||||
|
height (int): 라벨 높이
|
||||||
|
edge_size (int): 톱니 크기
|
||||||
|
steps (int): 톱니 반복 횟수
|
||||||
|
"""
|
||||||
|
top_points = []
|
||||||
|
step_x = width / (steps * 2)
|
||||||
|
for i in range(steps * 2 + 1):
|
||||||
|
x = i * step_x
|
||||||
|
y = 0 if i % 2 == 0 else edge_size
|
||||||
|
top_points.append((int(x), int(y)))
|
||||||
|
draw.line(top_points, fill="black", width=2)
|
||||||
|
|
||||||
|
bottom_points = []
|
||||||
|
for i in range(steps * 2 + 1):
|
||||||
|
x = i * step_x
|
||||||
|
y = height if i % 2 == 0 else height - edge_size
|
||||||
|
bottom_points.append((int(x), int(y)))
|
||||||
|
draw.line(bottom_points, fill="black", width=2)
|
||||||
|
|
||||||
|
left_points = []
|
||||||
|
step_y = height / (steps * 2)
|
||||||
|
for i in range(steps * 2 + 1):
|
||||||
|
y = i * step_y
|
||||||
|
x = 0 if i % 2 == 0 else edge_size
|
||||||
|
left_points.append((int(x), int(y)))
|
||||||
|
draw.line(left_points, fill="black", width=2)
|
||||||
|
|
||||||
|
right_points = []
|
||||||
|
for i in range(steps * 2 + 1):
|
||||||
|
y = i * step_y
|
||||||
|
x = width if i % 2 == 0 else width - edge_size
|
||||||
|
right_points.append((int(x), int(y)))
|
||||||
|
draw.line(right_points, fill="black", width=2)
|
||||||
|
|
||||||
|
|
||||||
|
def split_med_name(med_name):
|
||||||
|
"""
|
||||||
|
약품 이름을 표시용 이름과 시그니처 정보로 분리합니다.
|
||||||
|
|
||||||
|
Parameters:
|
||||||
|
med_name (str): 약품 이름
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
tuple: (표시용 약품 이름, 시그니처 정보, 분리 여부)
|
||||||
|
"""
|
||||||
|
units = ['mg', 'g', 'ml', '%']
|
||||||
|
pattern = r'(\d+(?:\.\d+)?(?:/\d+(?:\.\d+)?)*)\s*(' + '|'.join(units) + r')(?:/(' + '|'.join(units) + r'))?$'
|
||||||
|
korean_only = re.fullmatch(r'[가-힣]+', med_name) is not None
|
||||||
|
korean_and_num_eng = re.fullmatch(r'[가-힣a-zA-Z0-9/\.]+', med_name) is not None and not korean_only
|
||||||
|
med_name_display = med_name
|
||||||
|
signature_info = "청 춘 약 국"
|
||||||
|
split_occurred = False
|
||||||
|
if korean_only:
|
||||||
|
if len(med_name) >= 10:
|
||||||
|
match = re.search(pattern, med_name)
|
||||||
|
if match and match.start() >= 10:
|
||||||
|
med_name_display = med_name[:match.start()].strip()
|
||||||
|
signature_info = match.group(1) + match.group(2) + (f"/{match.group(3)}" if match.group(3) else "")
|
||||||
|
split_occurred = True
|
||||||
|
else:
|
||||||
|
med_name_display = med_name[:10]
|
||||||
|
# else 그대로 사용
|
||||||
|
elif korean_and_num_eng:
|
||||||
|
if len(med_name) >= 13:
|
||||||
|
match = re.search(pattern, med_name)
|
||||||
|
if match:
|
||||||
|
med_name_display = med_name[:match.start()].strip()
|
||||||
|
signature_info = match.group(1) + match.group(2) + (f"/{match.group(3)}" if match.group(3) else "")
|
||||||
|
split_occurred = True
|
||||||
|
else:
|
||||||
|
med_name_display = med_name[:12]
|
||||||
|
return med_name_display, signature_info, split_occurred
|
||||||
|
|
||||||
|
|
||||||
|
def should_left_align(med_name_display):
|
||||||
|
"""
|
||||||
|
약품 이름의 길이와 구성을 기반으로 좌측 정렬 여부를 결정합니다.
|
||||||
|
|
||||||
|
Parameters:
|
||||||
|
med_name_display (str): 분리된 약품 이름 표시 부분
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
bool: 좌측 정렬 여부
|
||||||
|
"""
|
||||||
|
korean_only = re.fullmatch(r'[가-힣]+', med_name_display) is not None
|
||||||
|
korean_and_num_eng = re.fullmatch(r'[가-힣a-zA-Z0-9/\.]+', med_name_display) is not None and not korean_only
|
||||||
|
|
||||||
|
if korean_only and len(med_name_display) >= 10: # 10글자부터 좌측정렬 (한글단독)
|
||||||
|
return True
|
||||||
|
if korean_and_num_eng and len(med_name_display) >= 13: # 13글자부터 좌측정렬 (한글+숫자+영문)
|
||||||
|
return True
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def normalize_medication_name(med_name):
|
||||||
|
"""
|
||||||
|
약품 이름을 정제하여 라벨에 표시할 형태로 변환
|
||||||
|
- 괄호 안 내용 제거: "디오탄정80밀리그램(발사르탄)" → "디오탄정80mg"
|
||||||
|
- 밀리그램 계열: "밀리그램", "밀리그람", "미리그램" → "mg"
|
||||||
|
- 마이크로그램 계열: "마이크로그램", "마이크로그람" → "μg"
|
||||||
|
- 그램 계열: "그램", "그람" → "g"
|
||||||
|
- 대괄호 제거: "[애엽이소프]" → ""
|
||||||
|
"""
|
||||||
|
if not med_name:
|
||||||
|
return med_name
|
||||||
|
|
||||||
|
# 1. 대괄호 및 내용 제거 (예: "오티렌F정[애엽이소프]" → "오티렌F정")
|
||||||
|
med_name = re.sub(r'\[.*?\]', '', med_name) # 완전한 대괄호 쌍 제거
|
||||||
|
med_name = re.sub(r'\[.*$', '', med_name) # 여는 괄호부터 끝까지 제거
|
||||||
|
|
||||||
|
# 2. 소괄호 및 내용 제거 (예: "디오탄정80밀리그램(발사르탄)" → "디오탄정80밀리그램")
|
||||||
|
med_name = re.sub(r'\(.*?\)', '', med_name) # 완전한 소괄호 쌍 제거
|
||||||
|
med_name = re.sub(r'\(.*$', '', med_name) # 여는 괄호부터 끝까지 제거
|
||||||
|
|
||||||
|
# 2-1. 언더스코어 뒤 내용 제거 (예: "리피토정10mg_(10.85mg/1정)" → "리피토정10mg")
|
||||||
|
med_name = re.sub(r'_.*$', '', med_name) # 언더스코어부터 끝까지 제거
|
||||||
|
|
||||||
|
# 3. 밀리그램 변환 (숫자와 함께 있는 경우 포함)
|
||||||
|
# "80밀리그램" → "80mg", "밀리그램" → "mg"
|
||||||
|
med_name = re.sub(r'밀리그램|밀리그람|미리그램|미리그람', 'mg', med_name)
|
||||||
|
|
||||||
|
# 4. 마이크로그램 변환
|
||||||
|
med_name = re.sub(r'마이크로그램|마이크로그람', 'μg', med_name)
|
||||||
|
|
||||||
|
# 5. 그램 변환 (단, mg/μg로 이미 변환된 것은 제외)
|
||||||
|
med_name = re.sub(r'(?<!m)(?<!μ)그램|그람', 'g', med_name)
|
||||||
|
|
||||||
|
# 6. 공백 정리 (연속된 공백을 하나로)
|
||||||
|
med_name = re.sub(r'\s+', ' ', med_name).strip()
|
||||||
|
|
||||||
|
return med_name
|
||||||
|
|
||||||
|
|
||||||
|
def create_label_image(patient_name, med_name, add_info, frequency, dosage, duration,
|
||||||
|
formulation_type, main_ingredient_code, dosage_form, administration_route,
|
||||||
|
label_name, unit, conversion_factor=None, storage_condition="실온보관",
|
||||||
|
custom_dosage_instruction=""):
|
||||||
|
"""
|
||||||
|
라벨 이미지를 생성합니다.
|
||||||
|
|
||||||
|
Parameters:
|
||||||
|
patient_name (str): 환자 이름
|
||||||
|
med_name (str): 약품 이름
|
||||||
|
add_info (str): 약품 효능 정보
|
||||||
|
frequency (int): 복용 횟수
|
||||||
|
dosage (float): 복용량
|
||||||
|
duration (int): 복용 일수
|
||||||
|
formulation_type (str): 제형 타입
|
||||||
|
main_ingredient_code (str): 주성분 코드
|
||||||
|
dosage_form (str): 복용 형태
|
||||||
|
administration_route (str): 투여 경로
|
||||||
|
label_name (str): 라벨 명칭
|
||||||
|
unit (str): 복용 단위
|
||||||
|
conversion_factor (float, optional): 환산계수
|
||||||
|
storage_condition (str, optional): 보관 조건
|
||||||
|
custom_dosage_instruction (str, optional): 커스텀 용법 텍스트
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
PIL.Image: 생성된 라벨 이미지
|
||||||
|
"""
|
||||||
|
# 약품 이름 정제 (밀리그램 → mg 등)
|
||||||
|
med_name = normalize_medication_name(med_name)
|
||||||
|
|
||||||
|
# 라벨 이미지 설정
|
||||||
|
label_width = 306 # 29mm 용지에 해당하는 너비 픽셀 수 (300 dpi 기준)
|
||||||
|
label_height = 380 # 라벨 높이를 380으로 확장하여 추가 정보 포함 (Glabel 기준 380 적당)
|
||||||
|
image = Image.new("1", (label_width, label_height), "white")
|
||||||
|
draw = ImageDraw.Draw(image)
|
||||||
|
|
||||||
|
# 폰트 설정 (여기서 폰트를 규정하고 요소에서 불러서 사용)
|
||||||
|
font_path = os.path.join(os.path.dirname(os.path.abspath(__file__)), "fonts", "malgunbd.ttf")
|
||||||
|
try:
|
||||||
|
patient_name_font = ImageFont.truetype(font_path, 44)
|
||||||
|
drug_name_font = ImageFont.truetype(font_path, 32)
|
||||||
|
info_font = ImageFont.truetype(font_path, 30)
|
||||||
|
signature_font = ImageFont.truetype(font_path, 32)
|
||||||
|
print_date_font = ImageFont.truetype(font_path, 20) # 조제일 폰트 추가
|
||||||
|
additional_info_font = ImageFont.truetype(font_path, 27) # 추가 정보 폰트
|
||||||
|
storage_condition_font = ImageFont.truetype(font_path, 27) # 보관 조건 폰트
|
||||||
|
except IOError:
|
||||||
|
patient_name_font = ImageFont.load_default()
|
||||||
|
drug_name_font = ImageFont.load_default()
|
||||||
|
info_font = ImageFont.load_default()
|
||||||
|
signature_font = ImageFont.load_default()
|
||||||
|
print_date_font = ImageFont.load_default() # 조제일 폰트 기본값 사용
|
||||||
|
additional_info_font = ImageFont.load_default() # 추가 정보 폰트 기본값 사용
|
||||||
|
storage_condition_font = ImageFont.load_default() # 보관 조건 폰트 기본값 사용
|
||||||
|
logging.warning("폰트 로드 실패. 기본 폰트 사용.")
|
||||||
|
|
||||||
|
# 중앙 정렬된 텍스트 출력 함수
|
||||||
|
def draw_centered_text(draw, text, y, font, max_width=None):
|
||||||
|
if not text:
|
||||||
|
return y
|
||||||
|
lines = []
|
||||||
|
if max_width:
|
||||||
|
words = re.findall(r'\S+', text)
|
||||||
|
current_line = ""
|
||||||
|
for word in words:
|
||||||
|
test_line = f"{current_line} {word}".strip()
|
||||||
|
bbox = draw.textbbox((0, 0), test_line, font=font)
|
||||||
|
w = bbox[2] - bbox[0]
|
||||||
|
if w <= max_width:
|
||||||
|
current_line = test_line
|
||||||
|
else:
|
||||||
|
if current_line:
|
||||||
|
lines.append(current_line)
|
||||||
|
current_line = word
|
||||||
|
if current_line:
|
||||||
|
lines.append(current_line)
|
||||||
|
else:
|
||||||
|
lines = [text]
|
||||||
|
for line in lines:
|
||||||
|
bbox = draw.textbbox((0, 0), line, font=font)
|
||||||
|
w, h = bbox[2] - bbox[0], bbox[3] - bbox[1]
|
||||||
|
draw.text(((label_width - w) / 2, y), line, font=font, fill="black")
|
||||||
|
y += h + 5
|
||||||
|
return y
|
||||||
|
|
||||||
|
def draw_left_aligned_text(draw, text, y, font, max_width=None):
|
||||||
|
if not text:
|
||||||
|
return y
|
||||||
|
lines = []
|
||||||
|
if max_width:
|
||||||
|
words = re.findall(r'\S+', text)
|
||||||
|
current_line = ""
|
||||||
|
for word in words:
|
||||||
|
test_line = f"{current_line} {word}".strip()
|
||||||
|
bbox = draw.textbbox((0, 0), test_line, font=font)
|
||||||
|
w = bbox[2] - bbox[0]
|
||||||
|
if w <= max_width:
|
||||||
|
current_line = test_line
|
||||||
|
else:
|
||||||
|
if current_line:
|
||||||
|
lines.append(current_line)
|
||||||
|
current_line = word
|
||||||
|
if current_line:
|
||||||
|
lines.append(current_line)
|
||||||
|
else:
|
||||||
|
lines = [text]
|
||||||
|
for line in lines:
|
||||||
|
bbox = draw.textbbox((0, 0), line, font=font)
|
||||||
|
w, h = bbox[2] - bbox[0], bbox[3] - bbox[1]
|
||||||
|
draw.text((10, y), line, font=font, fill="black")
|
||||||
|
y += h + 5
|
||||||
|
return y
|
||||||
|
|
||||||
|
def draw_fitted_single_line(draw, text, y, font, max_width, min_font_size=24):
|
||||||
|
"""
|
||||||
|
텍스트를 1줄로 강제 표시하되, 폰트 크기 자동 축소 → 잘라내기 순으로 처리
|
||||||
|
|
||||||
|
Parameters:
|
||||||
|
draw: ImageDraw.Draw 객체
|
||||||
|
text (str): 표시할 텍스트
|
||||||
|
y (int): Y 좌표
|
||||||
|
font: 기본 폰트
|
||||||
|
max_width (int): 최대 너비
|
||||||
|
min_font_size (int): 최소 폰트 크기 (기본값: 24)
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
int: 다음 줄의 Y 좌표
|
||||||
|
"""
|
||||||
|
if not text:
|
||||||
|
return y
|
||||||
|
|
||||||
|
font_path = os.path.join(os.path.dirname(os.path.abspath(__file__)), "fonts", "malgunbd.ttf")
|
||||||
|
original_font_size = 30 # info_font 기본 크기
|
||||||
|
|
||||||
|
# 1단계: 폰트 크기 자동 축소 (30px → 24px)
|
||||||
|
for font_size in range(original_font_size, min_font_size - 1, -1):
|
||||||
|
try:
|
||||||
|
test_font = ImageFont.truetype(font_path, font_size)
|
||||||
|
except IOError:
|
||||||
|
test_font = ImageFont.load_default()
|
||||||
|
|
||||||
|
bbox = draw.textbbox((0, 0), text, font=test_font)
|
||||||
|
w, h = bbox[2] - bbox[0], bbox[3] - bbox[1]
|
||||||
|
|
||||||
|
if w <= max_width:
|
||||||
|
# 크기가 맞으면 중앙 정렬로 그리기
|
||||||
|
draw.text(((label_width - w) / 2, y), text, font=test_font, fill="black")
|
||||||
|
return y + h + 5
|
||||||
|
|
||||||
|
# 2단계: 최소 폰트에도 안 맞으면 텍스트 잘라내기
|
||||||
|
try:
|
||||||
|
final_font = ImageFont.truetype(font_path, min_font_size)
|
||||||
|
except IOError:
|
||||||
|
final_font = ImageFont.load_default()
|
||||||
|
|
||||||
|
original_text = text
|
||||||
|
while len(text) > 3:
|
||||||
|
ellipsized = text + "..."
|
||||||
|
bbox = draw.textbbox((0, 0), ellipsized, font=final_font)
|
||||||
|
w, h = bbox[2] - bbox[0], bbox[3] - bbox[1]
|
||||||
|
|
||||||
|
if w <= max_width:
|
||||||
|
draw.text(((label_width - w) / 2, y), ellipsized, font=final_font, fill="black")
|
||||||
|
return y + h + 5
|
||||||
|
|
||||||
|
text = text[:-1]
|
||||||
|
|
||||||
|
# 최악의 경우: "..." 만 표시
|
||||||
|
bbox = draw.textbbox((0, 0), "...", font=final_font)
|
||||||
|
w, h = bbox[2] - bbox[0], bbox[3] - bbox[1]
|
||||||
|
draw.text(((label_width - w) / 2, y), "...", font=final_font, fill="black")
|
||||||
|
return y + h + 5
|
||||||
|
|
||||||
|
# 환자 이름 처리: 한글은 띄워쓰기, 영문은 동적 크기 조정
|
||||||
|
is_korean_name = all(ord('가') <= ord(char) <= ord('힣') or char.isspace() for char in patient_name if char.strip())
|
||||||
|
|
||||||
|
if is_korean_name:
|
||||||
|
# 한글 이름: 한 글자씩 띄우기 (기존 방식)
|
||||||
|
y_position = 10
|
||||||
|
formatted_patient_name = " ".join(patient_name)
|
||||||
|
y_position = draw_centered_text(draw, formatted_patient_name, y_position, patient_name_font, max_width=label_width - 40)
|
||||||
|
else:
|
||||||
|
# 영문 이름: 1줄에 맞추기 위해 폰트 크기 동적 조정 (44px → 최소 28px)
|
||||||
|
max_width_for_name = label_width - 40
|
||||||
|
min_font_size_1line = 28 # 1줄일 때 최소 폰트
|
||||||
|
min_font_size_2line = 20 # 2줄일 때 최소 폰트 (18px → 20px)
|
||||||
|
original_font_size = 44
|
||||||
|
|
||||||
|
# 폰트 크기를 줄여가며 1줄에 맞는 크기 찾기
|
||||||
|
fitted_font = patient_name_font
|
||||||
|
for font_size in range(original_font_size, min_font_size_1line - 1, -1):
|
||||||
|
try:
|
||||||
|
test_font = ImageFont.truetype(font_path, font_size)
|
||||||
|
except IOError:
|
||||||
|
test_font = ImageFont.load_default()
|
||||||
|
|
||||||
|
bbox = draw.textbbox((0, 0), patient_name, font=test_font)
|
||||||
|
w = bbox[2] - bbox[0]
|
||||||
|
|
||||||
|
if w <= max_width_for_name:
|
||||||
|
fitted_font = test_font
|
||||||
|
break
|
||||||
|
|
||||||
|
# 28px에도 안 맞으면 띄어쓰기 기준으로 2줄 처리 (22px 최소 폰트로)
|
||||||
|
bbox = draw.textbbox((0, 0), patient_name, font=fitted_font)
|
||||||
|
w = bbox[2] - bbox[0]
|
||||||
|
|
||||||
|
if w > max_width_for_name and ' ' in patient_name:
|
||||||
|
# 2줄 처리: 29px ~ 20px 범위에서 동적 조정
|
||||||
|
y_position = 5 # 2줄일 때는 위에서 시작
|
||||||
|
|
||||||
|
max_font_size_2line = 29 # 2줄일 때 최대 폰트
|
||||||
|
fitted_font_2line = patient_name_font
|
||||||
|
|
||||||
|
for font_size in range(max_font_size_2line, min_font_size_2line - 1, -1):
|
||||||
|
try:
|
||||||
|
test_font = ImageFont.truetype(font_path, font_size)
|
||||||
|
except IOError:
|
||||||
|
test_font = ImageFont.load_default()
|
||||||
|
|
||||||
|
# 각 줄이 라벨 너비에 맞는지 확인
|
||||||
|
words = patient_name.split(' ')
|
||||||
|
line1 = words[0]
|
||||||
|
line2 = ' '.join(words[1:]) if len(words) > 1 else ''
|
||||||
|
|
||||||
|
bbox1 = draw.textbbox((0, 0), line1, font=test_font)
|
||||||
|
w1 = bbox1[2] - bbox1[0]
|
||||||
|
|
||||||
|
bbox2 = draw.textbbox((0, 0), line2, font=test_font)
|
||||||
|
w2 = bbox2[2] - bbox2[0]
|
||||||
|
|
||||||
|
if w1 <= max_width_for_name and w2 <= max_width_for_name:
|
||||||
|
fitted_font_2line = test_font
|
||||||
|
break
|
||||||
|
|
||||||
|
# 띄어쓰기로 분리하여 2줄로 처리
|
||||||
|
words = patient_name.split(' ')
|
||||||
|
line1 = words[0]
|
||||||
|
line2 = ' '.join(words[1:]) if len(words) > 1 else ''
|
||||||
|
|
||||||
|
# 첫 번째 줄
|
||||||
|
bbox1 = draw.textbbox((0, 0), line1, font=fitted_font_2line)
|
||||||
|
w1, h1 = bbox1[2] - bbox1[0], bbox1[3] - bbox1[1]
|
||||||
|
draw.text(((label_width - w1) / 2, y_position), line1, font=fitted_font_2line, fill="black")
|
||||||
|
y_position += h1 + 1 # 줄 간격 축소 (2 → 1)
|
||||||
|
|
||||||
|
# 두 번째 줄
|
||||||
|
if line2:
|
||||||
|
bbox2 = draw.textbbox((0, 0), line2, font=fitted_font_2line)
|
||||||
|
w2, h2 = bbox2[2] - bbox2[0], bbox2[3] - bbox2[1]
|
||||||
|
draw.text(((label_width - w2) / 2, y_position), line2, font=fitted_font_2line, fill="black")
|
||||||
|
y_position += h2 + 5
|
||||||
|
else:
|
||||||
|
# 1줄로 표시: 한글과 동일한 위치에서 시작
|
||||||
|
y_position = 10
|
||||||
|
h = bbox[3] - bbox[1]
|
||||||
|
draw.text(((label_width - w) / 2, y_position), patient_name, font=fitted_font, fill="black")
|
||||||
|
y_position += h + 5
|
||||||
|
|
||||||
|
# 약품명 시작 위치 고정 (이름 길이와 관계없이 일정한 위치 보장)
|
||||||
|
DRUG_NAME_START_Y = 60 # 약품명 시작 위치를 y=60으로 고정
|
||||||
|
if y_position < DRUG_NAME_START_Y:
|
||||||
|
y_position = DRUG_NAME_START_Y
|
||||||
|
|
||||||
|
# 약품명 정제 (괄호 제거, 단위 변환 등)
|
||||||
|
med_name = normalize_medication_name(med_name)
|
||||||
|
|
||||||
|
med_name_display, signature_info, split_occurred = split_med_name(med_name)
|
||||||
|
if should_left_align(med_name_display):
|
||||||
|
y_position = draw_left_aligned_text(draw, med_name_display, y_position, drug_name_font, max_width=label_width - 40)
|
||||||
|
y_position = draw_fitted_single_line(draw, f"({add_info})", y_position, info_font, max_width=label_width - 40)
|
||||||
|
else:
|
||||||
|
y_position = draw_centered_text(draw, med_name_display, y_position, drug_name_font, max_width=label_width - 40)
|
||||||
|
y_position = draw_fitted_single_line(draw, f"({add_info})", y_position, info_font, max_width=label_width - 40)
|
||||||
|
if dosage and frequency and duration and unit:
|
||||||
|
formatted_dosage = format_dosage(dosage)
|
||||||
|
daily_dosage = dosage * frequency
|
||||||
|
total_amount = daily_dosage * duration
|
||||||
|
if round(dosage, 4) != round(dosage, 3):
|
||||||
|
total_amount = round(total_amount * 4) / 4
|
||||||
|
formatted_total_amount = str(int(total_amount)) if total_amount.is_integer() else f"{total_amount:.2f}".rstrip('0').rstrip('.')
|
||||||
|
converted_total = format_converted_total(dosage, frequency, duration, conversion_factor)
|
||||||
|
if converted_total is not None:
|
||||||
|
total_label = f"총{formatted_total_amount}{unit}/{duration}일분({converted_total})"
|
||||||
|
else:
|
||||||
|
total_label = f"총{formatted_total_amount}{unit}/{duration}일분"
|
||||||
|
y_position = draw_centered_text(draw, total_label, y_position, additional_info_font, max_width=label_width - 40)
|
||||||
|
box_height = 75 # 70 → 75로 증가 (하단 여백 확보)
|
||||||
|
box_margin = 10
|
||||||
|
box_width = label_width - 40
|
||||||
|
box_x1 = (label_width - box_width) // 2
|
||||||
|
box_x2 = box_x1 + box_width
|
||||||
|
box_y1 = y_position + box_margin
|
||||||
|
box_y2 = box_y1 + box_height
|
||||||
|
draw.rectangle([box_x1, box_y1, box_x2, box_y2], outline="black", width=2)
|
||||||
|
box_padding = 10
|
||||||
|
line_spacing = 5
|
||||||
|
box_text1 = f"{formatted_dosage}{unit}"
|
||||||
|
text1_size = info_font.getbbox(box_text1)
|
||||||
|
text1_height = text1_size[3] - text1_size[1]
|
||||||
|
frequency_text = custom_dosage_instruction.strip()
|
||||||
|
if not frequency_text:
|
||||||
|
if frequency == 1:
|
||||||
|
frequency_text = "아침"
|
||||||
|
elif frequency == 2:
|
||||||
|
frequency_text = "아침, 저녁"
|
||||||
|
elif frequency == 3:
|
||||||
|
frequency_text = "아침, 점심, 저녁"
|
||||||
|
elif frequency == 4:
|
||||||
|
frequency_text = "아침, 점심, 저녁, 취침"
|
||||||
|
|
||||||
|
# 4회 복용일 때는 작은 폰트 사용 (30px -> 24px)
|
||||||
|
frequency_font = info_font
|
||||||
|
if frequency == 4 and not custom_dosage_instruction.strip():
|
||||||
|
try:
|
||||||
|
frequency_font = ImageFont.truetype(font_path, 24)
|
||||||
|
except IOError:
|
||||||
|
frequency_font = info_font
|
||||||
|
|
||||||
|
text2_height = 0
|
||||||
|
if frequency_text:
|
||||||
|
text2_size = frequency_font.getbbox(frequency_text)
|
||||||
|
text2_height = text2_size[3] - text2_size[1]
|
||||||
|
total_text_height = text1_height + line_spacing + text2_height
|
||||||
|
center_y = (box_y1 + box_y2) // 2
|
||||||
|
adjustment = 7
|
||||||
|
start_y = center_y - (total_text_height // 2) - adjustment
|
||||||
|
y_temp = draw_centered_text(draw, box_text1, start_y, info_font, max_width=box_width)
|
||||||
|
if frequency_text:
|
||||||
|
text2_y = y_temp + line_spacing
|
||||||
|
draw_centered_text(draw, frequency_text, text2_y, frequency_font, max_width=box_width)
|
||||||
|
y_position = box_y2 + box_margin
|
||||||
|
if storage_condition:
|
||||||
|
storage_condition_text = f"{storage_condition}"
|
||||||
|
y_position = draw_centered_text(draw, storage_condition_text, y_position, storage_condition_font, max_width=label_width - 40)
|
||||||
|
|
||||||
|
# === 동적 레이아웃 시스템 ===
|
||||||
|
# 상단 컨텐츠의 최종 y_position과 하단 고정 영역의 공간을 계산하여 겹침 방지
|
||||||
|
|
||||||
|
# 1. 시그니처 텍스트 및 크기 계산
|
||||||
|
signature_text = signature_info if signature_info else "청 춘 약 국"
|
||||||
|
margin_val = int(0.1 * label_width)
|
||||||
|
box_width_sig = label_width - 2 * margin_val
|
||||||
|
try:
|
||||||
|
bbox = draw.textbbox((0, 0), signature_text, font=signature_font)
|
||||||
|
w_sig, h_sig = bbox[2] - bbox[0], bbox[3] - bbox[1]
|
||||||
|
scale_factor = box_width_sig / w_sig if w_sig != 0 else 1
|
||||||
|
scaled_font_size = max(1, int(22 * scale_factor))
|
||||||
|
scaled_font = ImageFont.truetype(font_path, scaled_font_size)
|
||||||
|
except IOError:
|
||||||
|
scaled_font = ImageFont.load_default()
|
||||||
|
logging.warning("시그니처 폰트 로드 실패. 기본 폰트 사용.")
|
||||||
|
bbox = draw.textbbox((0, 0), signature_text, font=scaled_font)
|
||||||
|
w_sig, h_sig = bbox[2] - bbox[0], bbox[3] - bbox[1]
|
||||||
|
|
||||||
|
# 2. 조제일 텍스트 크기 계산
|
||||||
|
print_date_text = f"조제일 : {datetime.datetime.now(KOR_TZ).strftime('%Y-%m-%d')}"
|
||||||
|
bbox = draw.textbbox((0, 0), print_date_text, font=print_date_font)
|
||||||
|
date_w, date_h = bbox[2] - bbox[0], bbox[3] - bbox[1]
|
||||||
|
|
||||||
|
# 3. 하단 고정 영역 필요 공간 계산
|
||||||
|
# 기본 패딩 설정
|
||||||
|
padding_top = int(h_sig * 0.1)
|
||||||
|
padding_bottom = int(h_sig * 0.5)
|
||||||
|
padding_sides = int(h_sig * 0.2)
|
||||||
|
|
||||||
|
# 시그니처 박스 높이
|
||||||
|
signature_box_height = h_sig + padding_top + padding_bottom
|
||||||
|
|
||||||
|
# 조제일과 시그니처 사이 간격
|
||||||
|
date_signature_gap = 5
|
||||||
|
|
||||||
|
# 하단 고정 영역 전체 높이 (조제일 + 간격 + 시그니처 + 하단 여백)
|
||||||
|
bottom_fixed_height = date_h + date_signature_gap + signature_box_height + 10
|
||||||
|
|
||||||
|
# 4. 충돌 감지 및 조정
|
||||||
|
# 상단 컨텐츠 하단 (y_position) + 최소 여백(2px)
|
||||||
|
content_bottom = y_position + 2
|
||||||
|
|
||||||
|
# 하단 고정 영역 시작점
|
||||||
|
bottom_fixed_start = label_height - bottom_fixed_height
|
||||||
|
|
||||||
|
# 겹침 여부 확인 (10px 이상 겹칠 때만 조정)
|
||||||
|
overlap = content_bottom - bottom_fixed_start
|
||||||
|
|
||||||
|
if overlap > 10:
|
||||||
|
# 겹침 발생! 조제일 제거로 공간 확보
|
||||||
|
logging.info(f"레이아웃 충돌 감지: {overlap}px 겹침. 조제일 제거로 조정.")
|
||||||
|
|
||||||
|
# 조제일 없이 하단 고정 영역 재계산
|
||||||
|
bottom_fixed_height = signature_box_height + 10
|
||||||
|
bottom_fixed_start = label_height - bottom_fixed_height
|
||||||
|
|
||||||
|
# 조제일 표시 안 함
|
||||||
|
show_date = False
|
||||||
|
|
||||||
|
# 여전히 겹치면 시그니처 패딩 축소
|
||||||
|
overlap = content_bottom - bottom_fixed_start
|
||||||
|
if overlap > 0:
|
||||||
|
logging.info(f"추가 충돌 감지: {overlap}px 겹침. 시그니처 패딩 축소.")
|
||||||
|
padding_top = max(2, int(h_sig * 0.05))
|
||||||
|
padding_bottom = max(2, int(h_sig * 0.2))
|
||||||
|
signature_box_height = h_sig + padding_top + padding_bottom
|
||||||
|
bottom_fixed_height = signature_box_height + 5
|
||||||
|
bottom_fixed_start = label_height - bottom_fixed_height
|
||||||
|
else:
|
||||||
|
# 여유 공간 충분
|
||||||
|
show_date = True
|
||||||
|
|
||||||
|
# 5. 조제일 그리기 (공간이 충분한 경우에만)
|
||||||
|
if show_date:
|
||||||
|
print_date_x = (label_width - date_w) / 2
|
||||||
|
print_date_y = label_height - bottom_fixed_height
|
||||||
|
draw.text((print_date_x, print_date_y), print_date_text, font=print_date_font, fill="black")
|
||||||
|
|
||||||
|
# 시그니처는 조제일 아래에 배치
|
||||||
|
signature_y_start = print_date_y + date_h + date_signature_gap
|
||||||
|
else:
|
||||||
|
# 시그니처는 하단 고정 시작점에 배치
|
||||||
|
signature_y_start = bottom_fixed_start
|
||||||
|
|
||||||
|
# 6. 시그니처 박스 그리기
|
||||||
|
box_x = (label_width - w_sig) / 2 - padding_sides
|
||||||
|
box_y = signature_y_start
|
||||||
|
box_x2 = box_x + w_sig + 2 * padding_sides
|
||||||
|
box_y2 = box_y + h_sig + padding_top + padding_bottom
|
||||||
|
draw.rectangle([(box_x, box_y), (box_x2, box_y2)], outline="black")
|
||||||
|
draw.text(((label_width - w_sig) / 2, box_y + padding_top), signature_text, font=scaled_font, fill="black")
|
||||||
|
|
||||||
|
draw_scissor_border(draw, label_width, label_height, edge_size=10, steps=20)
|
||||||
|
return image
|
||||||
|
|
||||||
|
|
||||||
|
def print_label(patient_name, med_name, add_info, frequency, dosage, duration,
|
||||||
|
formulation_type, main_ingredient_code, dosage_form, administration_route, label_name,
|
||||||
|
unit=None, conversion_factor=None, storage_condition="실온보관", custom_dosage_instruction="",
|
||||||
|
printer_ip=None, printer_model=None, label_type=None):
|
||||||
|
"""
|
||||||
|
라벨 이미지를 생성하여 프린터로 인쇄합니다.
|
||||||
|
|
||||||
|
Parameters:
|
||||||
|
patient_name (str): 환자 이름
|
||||||
|
med_name (str): 약품 이름
|
||||||
|
add_info (str): 약품 효능 정보
|
||||||
|
frequency (int): 복용 횟수
|
||||||
|
dosage (float): 복용량
|
||||||
|
duration (int): 복용 일수
|
||||||
|
formulation_type (str): 제형 타입
|
||||||
|
main_ingredient_code (str): 주성분 코드
|
||||||
|
dosage_form (str): 복용 형태
|
||||||
|
administration_route (str): 투여 경로
|
||||||
|
label_name (str): 라벨 명칭
|
||||||
|
unit (str, optional): 복용 단위
|
||||||
|
conversion_factor (float, optional): 환산계수
|
||||||
|
storage_condition (str, optional): 보관 조건
|
||||||
|
custom_dosage_instruction (str, optional): 커스텀 용법 텍스트
|
||||||
|
printer_ip (str, optional): 프린터 IP 주소 (기본값: DEFAULT_PRINTER_IP)
|
||||||
|
printer_model (str, optional): 프린터 모델 (기본값: DEFAULT_PRINTER_MODEL)
|
||||||
|
label_type (str, optional): 라벨 타입 (기본값: DEFAULT_LABEL_TYPE)
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
# 프린터 설정 적용 (전달되지 않으면 기본값 사용)
|
||||||
|
printer_ip = printer_ip or DEFAULT_PRINTER_IP
|
||||||
|
printer_model = printer_model or DEFAULT_PRINTER_MODEL
|
||||||
|
label_type = label_type or DEFAULT_LABEL_TYPE
|
||||||
|
|
||||||
|
if not unit:
|
||||||
|
if "캡슐" in med_name:
|
||||||
|
unit = "캡슐"
|
||||||
|
elif "정" in med_name:
|
||||||
|
unit = "정"
|
||||||
|
elif "시럽" in med_name:
|
||||||
|
unit = "ml"
|
||||||
|
elif "과립" in med_name or "시럽" in med_name:
|
||||||
|
unit = "g"
|
||||||
|
else:
|
||||||
|
unit = "개"
|
||||||
|
label_image = create_label_image(
|
||||||
|
patient_name=patient_name,
|
||||||
|
med_name=med_name,
|
||||||
|
add_info=add_info,
|
||||||
|
frequency=frequency,
|
||||||
|
dosage=dosage,
|
||||||
|
duration=duration,
|
||||||
|
formulation_type=formulation_type,
|
||||||
|
main_ingredient_code=main_ingredient_code,
|
||||||
|
dosage_form=dosage_form,
|
||||||
|
administration_route=administration_route,
|
||||||
|
label_name=label_name,
|
||||||
|
unit=unit,
|
||||||
|
conversion_factor=conversion_factor,
|
||||||
|
storage_condition=storage_condition,
|
||||||
|
custom_dosage_instruction=custom_dosage_instruction
|
||||||
|
)
|
||||||
|
image_stream = io.BytesIO()
|
||||||
|
label_image.save(image_stream, format="PNG")
|
||||||
|
image_stream.seek(0)
|
||||||
|
from brother_ql.raster import BrotherQLRaster
|
||||||
|
from brother_ql.conversion import convert
|
||||||
|
from brother_ql.backends.helpers import send
|
||||||
|
|
||||||
|
qlr = BrotherQLRaster(printer_model)
|
||||||
|
instructions = convert(
|
||||||
|
qlr=qlr,
|
||||||
|
images=[Image.open(image_stream)],
|
||||||
|
label=label_type,
|
||||||
|
rotate="0",
|
||||||
|
threshold=70.0,
|
||||||
|
dither=False,
|
||||||
|
compress=False,
|
||||||
|
lq=True,
|
||||||
|
red=False
|
||||||
|
)
|
||||||
|
send(instructions, printer_identifier=f"tcp://{printer_ip}:9100")
|
||||||
|
logging.info(f"라벨 인쇄 성공: 환자={patient_name}, 약품={med_name}, 커스텀 용법={custom_dosage_instruction}")
|
||||||
|
print(f"[SUCCESS] 라벨 인쇄 성공: 환자={patient_name}, 약품={med_name}, 커스텀 용법={custom_dosage_instruction}")
|
||||||
|
except Exception as e:
|
||||||
|
logging.error(f"라벨 인쇄 실패: {e}")
|
||||||
|
print(f"[ERROR] 라벨 인쇄 실패: {e}")
|
||||||
|
|
||||||
|
|
||||||
|
def print_custom_image(pil_image, printer_ip=None, printer_model=None, label_type=None):
|
||||||
|
"""
|
||||||
|
PIL 이미지를 받아 Brother QL 프린터로 인쇄합니다.
|
||||||
|
|
||||||
|
Parameters:
|
||||||
|
pil_image (PIL.Image): 인쇄할 이미지
|
||||||
|
printer_ip (str, optional): 프린터 IP 주소 (기본값: DEFAULT_PRINTER_IP)
|
||||||
|
printer_model (str, optional): 프린터 모델 (기본값: DEFAULT_PRINTER_MODEL)
|
||||||
|
label_type (str, optional): 라벨 타입 (기본값: DEFAULT_LABEL_TYPE)
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
# 프린터 설정 적용 (전달되지 않으면 기본값 사용)
|
||||||
|
printer_ip = printer_ip or DEFAULT_PRINTER_IP
|
||||||
|
printer_model = printer_model or DEFAULT_PRINTER_MODEL
|
||||||
|
label_type = label_type or DEFAULT_LABEL_TYPE
|
||||||
|
|
||||||
|
logging.info(f"이미지 모드: {pil_image.mode}")
|
||||||
|
if pil_image.mode in ('RGBA', 'LA'):
|
||||||
|
logging.info("알파 채널 있음 (RGBA 또는 LA 모드)")
|
||||||
|
elif pil_image.mode == 'P' and 'transparency' in pil_image.info:
|
||||||
|
logging.info("알파 채널 있음 (팔레트 모드, transparency 키 확인됨)")
|
||||||
|
else:
|
||||||
|
logging.info("알파 채널 없음")
|
||||||
|
pil_image = pil_image.rotate(90, expand=True)
|
||||||
|
width, height = pil_image.size
|
||||||
|
new_height = int((306 / width) * height)
|
||||||
|
pil_image = pil_image.resize((306, new_height), Image.LANCZOS)
|
||||||
|
if pil_image.mode in ('RGBA', 'LA') or (pil_image.mode == 'P' and 'transparency' in pil_image.info):
|
||||||
|
background = Image.new("RGB", pil_image.size, "white")
|
||||||
|
background.paste(pil_image, mask=pil_image.split()[-1])
|
||||||
|
pil_image = background
|
||||||
|
image_stream = io.BytesIO()
|
||||||
|
pil_image.convert('1').save(image_stream, format="PNG")
|
||||||
|
image_stream.seek(0)
|
||||||
|
from brother_ql.raster import BrotherQLRaster
|
||||||
|
from brother_ql.conversion import convert
|
||||||
|
from brother_ql.backends.helpers import send
|
||||||
|
|
||||||
|
# Brother QL 프린터로 전송
|
||||||
|
qlr = BrotherQLRaster(printer_model)
|
||||||
|
instructions = convert(
|
||||||
|
qlr=qlr,
|
||||||
|
images=[Image.open(image_stream)],
|
||||||
|
label=label_type,
|
||||||
|
rotate="0", # 라벨 회전 없음
|
||||||
|
threshold=70.0, # 흑백 변환 임계값
|
||||||
|
dither=False,
|
||||||
|
compress=False,
|
||||||
|
lq=True, # 저화질 인쇄 옵션
|
||||||
|
red=False
|
||||||
|
)
|
||||||
|
send(instructions, printer_identifier=f"tcp://{printer_ip}:9100")
|
||||||
|
logging.info("커스텀 이미지 인쇄 성공")
|
||||||
|
print("[SUCCESS] 커스텀 이미지 인쇄 성공")
|
||||||
|
except Exception as e:
|
||||||
|
logging.error(f"커스텀 이미지 인쇄 실패: {e}")
|
||||||
|
print(f"[ERROR] 커스텀 이미지 인쇄 실패: {e}")
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
# 인터랙티브 메뉴를 통해 샘플 인쇄 선택
|
||||||
|
|
||||||
|
samples = {
|
||||||
|
"1": {
|
||||||
|
"patient_name": "이영희",
|
||||||
|
"med_name": "아모크라정375mg",
|
||||||
|
"add_info": "고혈압",
|
||||||
|
"frequency": 1,
|
||||||
|
"dosage": 375.0,
|
||||||
|
"duration": 30,
|
||||||
|
"formulation_type": "정제",
|
||||||
|
"main_ingredient_code": "AMO375",
|
||||||
|
"dosage_form": "경구",
|
||||||
|
"administration_route": "경구",
|
||||||
|
"label_name": "고혈압용",
|
||||||
|
"unit": None,
|
||||||
|
"conversion_factor": 1.0,
|
||||||
|
"storage_condition": "실온보관",
|
||||||
|
"custom_dosage_instruction": ""
|
||||||
|
},
|
||||||
|
"2": {
|
||||||
|
"patient_name": "박지성",
|
||||||
|
"med_name": "삼남아세트아미노펜정500mg",
|
||||||
|
"add_info": "통증 완화",
|
||||||
|
"frequency": 2,
|
||||||
|
"dosage": 500.0,
|
||||||
|
"duration": 5,
|
||||||
|
"formulation_type": "정제",
|
||||||
|
"main_ingredient_code": "MED001", # 예시용
|
||||||
|
"dosage_form": "경구",
|
||||||
|
"administration_route": "경구",
|
||||||
|
"label_name": "통증용",
|
||||||
|
"unit": None,
|
||||||
|
"conversion_factor": 1.0,
|
||||||
|
"storage_condition": "서늘한 곳에 보관",
|
||||||
|
"custom_dosage_instruction": ""
|
||||||
|
},
|
||||||
|
"3": {
|
||||||
|
"patient_name": "최민수",
|
||||||
|
"med_name": "세레타이드125에보할러",
|
||||||
|
"add_info": "알레르기 치료",
|
||||||
|
"frequency": 3,
|
||||||
|
"dosage": 125.0,
|
||||||
|
"duration": 10,
|
||||||
|
"formulation_type": "정제",
|
||||||
|
"main_ingredient_code": "SER125",
|
||||||
|
"dosage_form": "흡입",
|
||||||
|
"administration_route": "흡입",
|
||||||
|
"label_name": "알레르기용",
|
||||||
|
"unit": None,
|
||||||
|
"conversion_factor": 1.0,
|
||||||
|
"storage_condition": "냉장보관",
|
||||||
|
"custom_dosage_instruction": ""
|
||||||
|
},
|
||||||
|
"4": {
|
||||||
|
"patient_name": "최민수",
|
||||||
|
"med_name": "트윈스타정40/5mg",
|
||||||
|
"add_info": "혈압 조절",
|
||||||
|
"frequency": 2,
|
||||||
|
"dosage": 40.0,
|
||||||
|
"duration": 10,
|
||||||
|
"formulation_type": "정제",
|
||||||
|
"main_ingredient_code": "TW40",
|
||||||
|
"dosage_form": "경구",
|
||||||
|
"administration_route": "경구",
|
||||||
|
"label_name": "고혈압용",
|
||||||
|
"unit": None,
|
||||||
|
"conversion_factor": 1.0,
|
||||||
|
"storage_condition": "실온보관",
|
||||||
|
"custom_dosage_instruction": ""
|
||||||
|
},
|
||||||
|
"5": {
|
||||||
|
"patient_name": "최우주",
|
||||||
|
"med_name": "오셀타원현탁용분말6mg/mL",
|
||||||
|
"add_info": "오셀타미",
|
||||||
|
"frequency": 2,
|
||||||
|
"dosage": 4.0,
|
||||||
|
"duration": 5,
|
||||||
|
"formulation_type": "현탁용분말",
|
||||||
|
"main_ingredient_code": "358907ASS",
|
||||||
|
"dosage_form": "SS",
|
||||||
|
"administration_route": "A",
|
||||||
|
"label_name": "오셀타원현탁용분말6mg/mL",
|
||||||
|
"unit": "ml",
|
||||||
|
"conversion_factor": 0.126,
|
||||||
|
"storage_condition": "실온및(냉장)",
|
||||||
|
"custom_dosage_instruction": ""
|
||||||
|
},
|
||||||
|
"6": {
|
||||||
|
"patient_name": "최우주",
|
||||||
|
"med_name": "어린이타이레놀현탁액",
|
||||||
|
"add_info": "해열,진통제",
|
||||||
|
"frequency": 3,
|
||||||
|
"dosage": 3.0,
|
||||||
|
"duration": 3,
|
||||||
|
"formulation_type": "현탁액",
|
||||||
|
"main_ingredient_code": "101330ASS",
|
||||||
|
"dosage_form": "SS",
|
||||||
|
"administration_route": "A",
|
||||||
|
"label_name": "어린이타이레놀현탁액",
|
||||||
|
"unit": "ml",
|
||||||
|
"conversion_factor": None,
|
||||||
|
"storage_condition": "실온보관",
|
||||||
|
"custom_dosage_instruction": ""
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
print("=======================================")
|
||||||
|
print(" 라벨 인쇄 샘플 선택 ")
|
||||||
|
print("=======================================")
|
||||||
|
for key, sample in samples.items():
|
||||||
|
print(f"{key}: {sample['patient_name']} / {sample['med_name']} / {sample['add_info']}")
|
||||||
|
print("q: 종료")
|
||||||
|
|
||||||
|
choice = input("인쇄할 샘플 번호를 선택하세요: ").strip()
|
||||||
|
while choice.lower() != 'q':
|
||||||
|
if choice in samples:
|
||||||
|
sample = samples[choice]
|
||||||
|
print(f"선택한 샘플: {sample['patient_name']} / {sample['med_name']}")
|
||||||
|
print_label(
|
||||||
|
patient_name=sample["patient_name"],
|
||||||
|
med_name=sample["med_name"],
|
||||||
|
add_info=sample["add_info"],
|
||||||
|
frequency=sample["frequency"],
|
||||||
|
dosage=sample["dosage"],
|
||||||
|
duration=sample["duration"],
|
||||||
|
formulation_type=sample["formulation_type"],
|
||||||
|
main_ingredient_code=sample["main_ingredient_code"],
|
||||||
|
dosage_form=sample["dosage_form"],
|
||||||
|
administration_route=sample["administration_route"],
|
||||||
|
label_name=sample["label_name"],
|
||||||
|
unit=sample["unit"],
|
||||||
|
conversion_factor=sample["conversion_factor"],
|
||||||
|
storage_condition=sample["storage_condition"],
|
||||||
|
custom_dosage_instruction=sample["custom_dosage_instruction"]
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
print("올바른 번호를 입력하세요.")
|
||||||
|
choice = input("인쇄할 샴플 번호를 선택하세요 (종료하려면 q 입력): ").strip()
|
||||||
28
backend/samples/printers.json
Normal file
28
backend/samples/printers.json
Normal file
@ -0,0 +1,28 @@
|
|||||||
|
{
|
||||||
|
"printers": [
|
||||||
|
{
|
||||||
|
"id": "printer_1",
|
||||||
|
"name": "메인 프린터 (QL-710W)",
|
||||||
|
"model": "QL-710W",
|
||||||
|
"ip_address": "192.168.0.121",
|
||||||
|
"port": 9100,
|
||||||
|
"label_type": "29",
|
||||||
|
"is_default": true,
|
||||||
|
"is_active": true,
|
||||||
|
"description": "1층 조제실 메인 프린터",
|
||||||
|
"location": "조제실"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "printer_2",
|
||||||
|
"name": "보조 프린터 (QL-810W)",
|
||||||
|
"model": "QL-810W",
|
||||||
|
"ip_address": "192.168.0.168",
|
||||||
|
"port": 9100,
|
||||||
|
"label_type": "29",
|
||||||
|
"is_default": false,
|
||||||
|
"is_active": true,
|
||||||
|
"description": "2층 조제실 보조 프린터",
|
||||||
|
"location": "투약대"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
1368
backend/samples/product_label.py
Normal file
1368
backend/samples/product_label.py
Normal file
File diff suppressed because it is too large
Load Diff
470
backend/templates/admin.html
Normal file
470
backend/templates/admin.html
Normal file
@ -0,0 +1,470 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="ko">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>관리자 페이지 - 청춘약국</title>
|
||||||
|
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||||
|
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||||
|
<link href="https://fonts.googleapis.com/css2?family=Noto+Sans+KR:wght@400;500;700&display=swap" rel="stylesheet">
|
||||||
|
<style>
|
||||||
|
* {
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
font-family: 'Noto Sans KR', -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
|
||||||
|
background: #f5f7fa;
|
||||||
|
-webkit-font-smoothing: antialiased;
|
||||||
|
}
|
||||||
|
|
||||||
|
.header {
|
||||||
|
background: linear-gradient(135deg, #6366f1 0%, #8b5cf6 100%);
|
||||||
|
padding: 32px 24px;
|
||||||
|
color: #ffffff;
|
||||||
|
box-shadow: 0 2px 8px rgba(0,0,0,0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.header-content {
|
||||||
|
max-width: 1400px;
|
||||||
|
margin: 0 auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.header-title {
|
||||||
|
font-size: 28px;
|
||||||
|
font-weight: 700;
|
||||||
|
margin-bottom: 8px;
|
||||||
|
letter-spacing: -0.5px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.header-subtitle {
|
||||||
|
font-size: 15px;
|
||||||
|
opacity: 0.9;
|
||||||
|
font-weight: 500;
|
||||||
|
letter-spacing: -0.2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.container {
|
||||||
|
max-width: 1400px;
|
||||||
|
margin: 0 auto;
|
||||||
|
padding: 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stats-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fit, minmax(280px, 1fr));
|
||||||
|
gap: 20px;
|
||||||
|
margin-bottom: 32px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stat-card {
|
||||||
|
background: #ffffff;
|
||||||
|
border-radius: 16px;
|
||||||
|
padding: 24px;
|
||||||
|
box-shadow: 0 2px 8px rgba(0,0,0,0.04);
|
||||||
|
}
|
||||||
|
|
||||||
|
.stat-label {
|
||||||
|
color: #868e96;
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 600;
|
||||||
|
margin-bottom: 12px;
|
||||||
|
letter-spacing: -0.2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stat-value {
|
||||||
|
color: #212529;
|
||||||
|
font-size: 36px;
|
||||||
|
font-weight: 700;
|
||||||
|
letter-spacing: -1px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stat-value.primary {
|
||||||
|
color: #6366f1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.section {
|
||||||
|
background: #ffffff;
|
||||||
|
border-radius: 16px;
|
||||||
|
padding: 24px;
|
||||||
|
margin-bottom: 24px;
|
||||||
|
box-shadow: 0 2px 8px rgba(0,0,0,0.04);
|
||||||
|
}
|
||||||
|
|
||||||
|
.section-title {
|
||||||
|
font-size: 20px;
|
||||||
|
font-weight: 700;
|
||||||
|
color: #212529;
|
||||||
|
margin-bottom: 20px;
|
||||||
|
letter-spacing: -0.3px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.table-responsive {
|
||||||
|
overflow-x: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
table {
|
||||||
|
width: 100%;
|
||||||
|
border-collapse: collapse;
|
||||||
|
}
|
||||||
|
|
||||||
|
th {
|
||||||
|
text-align: left;
|
||||||
|
padding: 12px 16px;
|
||||||
|
background: #f8f9fa;
|
||||||
|
color: #495057;
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 600;
|
||||||
|
border-bottom: 2px solid #e9ecef;
|
||||||
|
letter-spacing: -0.2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
td {
|
||||||
|
padding: 14px 16px;
|
||||||
|
border-bottom: 1px solid #f1f3f5;
|
||||||
|
color: #495057;
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 500;
|
||||||
|
letter-spacing: -0.2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
tr:hover {
|
||||||
|
background: #f8f9fa;
|
||||||
|
}
|
||||||
|
|
||||||
|
.badge {
|
||||||
|
display: inline-block;
|
||||||
|
padding: 4px 12px;
|
||||||
|
border-radius: 100px;
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 600;
|
||||||
|
letter-spacing: -0.2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.badge-success {
|
||||||
|
background: #d3f9d8;
|
||||||
|
color: #2b8a3e;
|
||||||
|
}
|
||||||
|
|
||||||
|
.badge-warning {
|
||||||
|
background: #fff3bf;
|
||||||
|
color: #e67700;
|
||||||
|
}
|
||||||
|
|
||||||
|
.points-positive {
|
||||||
|
color: #6366f1;
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
|
.phone-masked {
|
||||||
|
font-family: 'Courier New', monospace;
|
||||||
|
color: #495057;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
.stats-grid {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
|
||||||
|
.table-responsive {
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
th, td {
|
||||||
|
padding: 10px 12px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="header">
|
||||||
|
<div class="header-content">
|
||||||
|
<div class="header-title">📊 관리자 대시보드</div>
|
||||||
|
<div class="header-subtitle">청춘약국 마일리지 관리</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="container">
|
||||||
|
<!-- 전체 통계 -->
|
||||||
|
<div class="stats-grid">
|
||||||
|
<div class="stat-card">
|
||||||
|
<div class="stat-label">총 가입자 수</div>
|
||||||
|
<div class="stat-value primary">{{ stats.total_users or 0 }}명</div>
|
||||||
|
</div>
|
||||||
|
<div class="stat-card">
|
||||||
|
<div class="stat-label">누적 포인트 잔액</div>
|
||||||
|
<div class="stat-value primary">{{ "{:,}".format(stats.total_balance or 0) }}P</div>
|
||||||
|
</div>
|
||||||
|
<div class="stat-card">
|
||||||
|
<div class="stat-label">QR 발행 건수</div>
|
||||||
|
<div class="stat-value">{{ token_stats.total_tokens or 0 }}건</div>
|
||||||
|
</div>
|
||||||
|
<div class="stat-card">
|
||||||
|
<div class="stat-label">적립 완료율</div>
|
||||||
|
<div class="stat-value">
|
||||||
|
{% if token_stats.total_tokens and token_stats.total_tokens > 0 %}
|
||||||
|
{{ "%.1f"|format((token_stats.claimed_count or 0) * 100.0 / token_stats.total_tokens) }}%
|
||||||
|
{% else %}
|
||||||
|
0%
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 최근 가입 사용자 -->
|
||||||
|
<div class="section">
|
||||||
|
<div class="section-title">최근 가입 사용자 (20명)</div>
|
||||||
|
<div class="table-responsive">
|
||||||
|
{% if recent_users %}
|
||||||
|
<table>
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>ID</th>
|
||||||
|
<th>이름</th>
|
||||||
|
<th>전화번호</th>
|
||||||
|
<th>포인트</th>
|
||||||
|
<th>가입일</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{% for user in recent_users %}
|
||||||
|
<tr>
|
||||||
|
<td>{{ user.id }}</td>
|
||||||
|
<td>{{ user.nickname }}</td>
|
||||||
|
<td class="phone-masked">{{ user.phone[:3] }}-{{ user.phone[3:7] }}-{{ user.phone[7:] if user.phone|length > 7 else '' }}</td>
|
||||||
|
<td class="points-positive">{{ "{:,}".format(user.mileage_balance) }}P</td>
|
||||||
|
<td>{{ user.created_at[:16].replace('T', ' ') }}</td>
|
||||||
|
</tr>
|
||||||
|
{% endfor %}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
{% else %}
|
||||||
|
<p style="text-align: center; padding: 40px; color: #868e96;">가입한 사용자가 없습니다.</p>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 최근 적립 내역 -->
|
||||||
|
<div class="section">
|
||||||
|
<div class="section-title">최근 적립 내역 (50건)</div>
|
||||||
|
<div class="table-responsive">
|
||||||
|
{% if recent_transactions %}
|
||||||
|
<table>
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>이름</th>
|
||||||
|
<th>전화번호</th>
|
||||||
|
<th>포인트</th>
|
||||||
|
<th>잔액</th>
|
||||||
|
<th>내역</th>
|
||||||
|
<th>일시</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{% for tx in recent_transactions %}
|
||||||
|
<tr>
|
||||||
|
<td>{{ tx.nickname }}</td>
|
||||||
|
<td class="phone-masked">{{ tx.phone[:3] }}-{{ tx.phone[3:7] }}-{{ tx.phone[7:] if tx.phone|length > 7 else '' }}</td>
|
||||||
|
<td class="points-positive">{{ "{:,}".format(tx.points) }}P</td>
|
||||||
|
<td>{{ "{:,}".format(tx.balance_after) }}P</td>
|
||||||
|
<td>{{ tx.description or tx.reason }}</td>
|
||||||
|
<td>{{ tx.created_at[:16].replace('T', ' ') }}</td>
|
||||||
|
</tr>
|
||||||
|
{% endfor %}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
{% else %}
|
||||||
|
<p style="text-align: center; padding: 40px; color: #868e96;">적립 내역이 없습니다.</p>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 최근 QR 발행 내역 -->
|
||||||
|
<div class="section">
|
||||||
|
<div class="section-title">최근 QR 발행 내역 (20건)</div>
|
||||||
|
<div class="table-responsive">
|
||||||
|
{% if recent_tokens %}
|
||||||
|
<table>
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>거래번호</th>
|
||||||
|
<th>판매금액</th>
|
||||||
|
<th>적립포인트</th>
|
||||||
|
<th>상태</th>
|
||||||
|
<th>발행일</th>
|
||||||
|
<th>적립일</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{% for token in recent_tokens %}
|
||||||
|
<tr>
|
||||||
|
<td>
|
||||||
|
<a href="javascript:void(0)" onclick="showTransactionDetail('{{ token.transaction_id }}')" style="color: #6366f1; text-decoration: none; font-weight: 600; cursor: pointer;">
|
||||||
|
{{ token.transaction_id }}
|
||||||
|
</a>
|
||||||
|
</td>
|
||||||
|
<td>{{ "{:,}".format(token.total_amount) }}원</td>
|
||||||
|
<td class="points-positive">{{ "{:,}".format(token.claimable_points) }}P</td>
|
||||||
|
<td>
|
||||||
|
{% if token.claimed_at %}
|
||||||
|
<span class="badge badge-success">적립완료</span>
|
||||||
|
{% else %}
|
||||||
|
<span class="badge badge-warning">대기중</span>
|
||||||
|
{% endif %}
|
||||||
|
</td>
|
||||||
|
<td>{{ token.created_at[:16].replace('T', ' ') }}</td>
|
||||||
|
<td>{{ token.claimed_at[:16].replace('T', ' ') if token.claimed_at else '-' }}</td>
|
||||||
|
</tr>
|
||||||
|
{% endfor %}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
{% else %}
|
||||||
|
<p style="text-align: center; padding: 40px; color: #868e96;">발행된 QR이 없습니다.</p>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 거래 세부 내역 모달 -->
|
||||||
|
<div id="transactionModal" style="display: none; position: fixed; top: 0; left: 0; right: 0; bottom: 0; background: rgba(0,0,0,0.5); z-index: 9999; padding: 20px; overflow-y: auto;">
|
||||||
|
<div style="max-width: 800px; margin: 40px auto; background: #fff; border-radius: 20px; padding: 32px; position: relative;">
|
||||||
|
<button onclick="closeModal()" style="position: absolute; top: 20px; right: 20px; background: #f1f3f5; border: none; width: 36px; height: 36px; border-radius: 50%; cursor: pointer; font-size: 20px; color: #495057;">×</button>
|
||||||
|
|
||||||
|
<h2 style="font-size: 24px; font-weight: 700; color: #212529; margin-bottom: 24px; letter-spacing: -0.5px;">판매 내역 상세</h2>
|
||||||
|
|
||||||
|
<div id="transactionContent" style="min-height: 200px;">
|
||||||
|
<div style="text-align: center; padding: 60px; color: #868e96;">
|
||||||
|
<div style="font-size: 14px;">불러오는 중...</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
function showTransactionDetail(transactionId) {
|
||||||
|
document.getElementById('transactionModal').style.display = 'block';
|
||||||
|
document.getElementById('transactionContent').innerHTML = '<div style="text-align: center; padding: 60px; color: #868e96;"><div style="font-size: 14px;">불러오는 중...</div></div>';
|
||||||
|
|
||||||
|
fetch(`/admin/transaction/${transactionId}`)
|
||||||
|
.then(response => response.json())
|
||||||
|
.then(data => {
|
||||||
|
if (data.success) {
|
||||||
|
renderTransactionDetail(data);
|
||||||
|
} else {
|
||||||
|
document.getElementById('transactionContent').innerHTML = `
|
||||||
|
<div style="text-align: center; padding: 60px; color: #f03e3e;">
|
||||||
|
<div style="font-size: 16px; font-weight: 600; margin-bottom: 8px;">오류</div>
|
||||||
|
<div style="font-size: 14px;">${data.message}</div>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.catch(error => {
|
||||||
|
document.getElementById('transactionContent').innerHTML = `
|
||||||
|
<div style="text-align: center; padding: 60px; color: #f03e3e;">
|
||||||
|
<div style="font-size: 16px; font-weight: 600; margin-bottom: 8px;">네트워크 오류</div>
|
||||||
|
<div style="font-size: 14px;">데이터를 불러올 수 없습니다.</div>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderTransactionDetail(data) {
|
||||||
|
const tx = data.transaction;
|
||||||
|
const items = data.items;
|
||||||
|
|
||||||
|
let html = `
|
||||||
|
<div style="background: #f8f9fa; border-radius: 12px; padding: 20px; margin-bottom: 24px;">
|
||||||
|
<div style="display: grid; grid-template-columns: repeat(2, 1fr); gap: 16px;">
|
||||||
|
<div>
|
||||||
|
<div style="color: #868e96; font-size: 13px; margin-bottom: 6px;">거래번호</div>
|
||||||
|
<div style="color: #212529; font-size: 16px; font-weight: 600;">${tx.id}</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div style="color: #868e96; font-size: 13px; margin-bottom: 6px;">거래일시</div>
|
||||||
|
<div style="color: #212529; font-size: 16px; font-weight: 600;">${tx.date}</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div style="color: #868e96; font-size: 13px; margin-bottom: 6px;">고객명</div>
|
||||||
|
<div style="color: #212529; font-size: 16px; font-weight: 600;">${tx.customer_name}</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div style="color: #868e96; font-size: 13px; margin-bottom: 6px;">총 금액</div>
|
||||||
|
<div style="color: #495057; font-size: 16px; font-weight: 600;">${tx.total_amount.toLocaleString()}원</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div style="color: #868e96; font-size: 13px; margin-bottom: 6px;">할인</div>
|
||||||
|
<div style="color: #f03e3e; font-size: 16px; font-weight: 600;">-${tx.discount.toLocaleString()}원</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div style="color: #868e96; font-size: 13px; margin-bottom: 6px;">판매 금액</div>
|
||||||
|
<div style="color: #6366f1; font-size: 18px; font-weight: 700;">${tx.sale_amount.toLocaleString()}원</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div style="color: #868e96; font-size: 13px; margin-bottom: 6px;">외상</div>
|
||||||
|
<div style="color: #212529; font-size: 16px; font-weight: 600;">${tx.credit.toLocaleString()}원</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div style="color: #868e96; font-size: 13px; margin-bottom: 6px;">수금</div>
|
||||||
|
<div style="color: #37b24d; font-size: 16px; font-weight: 600;">${tx.received.toLocaleString()}원</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style="margin-bottom: 16px; font-size: 18px; font-weight: 700; color: #212529;">판매 상품 (${items.length}개)</div>
|
||||||
|
|
||||||
|
<table style="width: 100%; border-collapse: collapse;">
|
||||||
|
<thead>
|
||||||
|
<tr style="background: #f8f9fa; border-bottom: 2px solid #e9ecef;">
|
||||||
|
<th style="padding: 12px; text-align: left; font-size: 13px; color: #495057; font-weight: 600;">상품코드</th>
|
||||||
|
<th style="padding: 12px; text-align: left; font-size: 13px; color: #495057; font-weight: 600;">상품명</th>
|
||||||
|
<th style="padding: 12px; text-align: right; font-size: 13px; color: #495057; font-weight: 600;">수량</th>
|
||||||
|
<th style="padding: 12px; text-align: right; font-size: 13px; color: #495057; font-weight: 600;">단가</th>
|
||||||
|
<th style="padding: 12px; text-align: right; font-size: 13px; color: #495057; font-weight: 600;">합계</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
`;
|
||||||
|
|
||||||
|
items.forEach(item => {
|
||||||
|
html += `
|
||||||
|
<tr style="border-bottom: 1px solid #f1f3f5;">
|
||||||
|
<td style="padding: 14px; font-size: 14px; color: #495057;">${item.code}</td>
|
||||||
|
<td style="padding: 14px; font-size: 14px; color: #212529; font-weight: 500;">${item.name}</td>
|
||||||
|
<td style="padding: 14px; text-align: right; font-size: 14px; color: #495057;">${item.qty}</td>
|
||||||
|
<td style="padding: 14px; text-align: right; font-size: 14px; color: #495057;">${item.price.toLocaleString()}원</td>
|
||||||
|
<td style="padding: 14px; text-align: right; font-size: 14px; color: #6366f1; font-weight: 600;">${item.total.toLocaleString()}원</td>
|
||||||
|
</tr>
|
||||||
|
`;
|
||||||
|
});
|
||||||
|
|
||||||
|
html += `
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
`;
|
||||||
|
|
||||||
|
document.getElementById('transactionContent').innerHTML = html;
|
||||||
|
}
|
||||||
|
|
||||||
|
function closeModal() {
|
||||||
|
document.getElementById('transactionModal').style.display = 'none';
|
||||||
|
}
|
||||||
|
|
||||||
|
// ESC 키로 모달 닫기
|
||||||
|
document.addEventListener('keydown', function(e) {
|
||||||
|
if (e.key === 'Escape') {
|
||||||
|
closeModal();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// 모달 배경 클릭 시 닫기
|
||||||
|
document.getElementById('transactionModal').addEventListener('click', function(e) {
|
||||||
|
if (e.target === this) {
|
||||||
|
closeModal();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
506
backend/templates/claim_form.html
Normal file
506
backend/templates/claim_form.html
Normal file
@ -0,0 +1,506 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="ko">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no">
|
||||||
|
<title>포인트 적립 - 청춘약국</title>
|
||||||
|
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||||
|
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||||
|
<link href="https://fonts.googleapis.com/css2?family=Noto+Sans+KR:wght@400;500;700&display=swap" rel="stylesheet">
|
||||||
|
<style>
|
||||||
|
* {
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
font-family: 'Noto Sans KR', -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
|
||||||
|
background: #f5f7fa;
|
||||||
|
min-height: 100vh;
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
align-items: center;
|
||||||
|
padding: 16px;
|
||||||
|
-webkit-font-smoothing: antialiased;
|
||||||
|
}
|
||||||
|
|
||||||
|
.app-container {
|
||||||
|
background: #ffffff;
|
||||||
|
border-radius: 24px;
|
||||||
|
max-width: 420px;
|
||||||
|
width: 100%;
|
||||||
|
box-shadow: 0 4px 24px rgba(0, 0, 0, 0.06);
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.header {
|
||||||
|
background: linear-gradient(135deg, #6366f1 0%, #8b5cf6 100%);
|
||||||
|
padding: 32px 24px 140px 24px;
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
.header::after {
|
||||||
|
content: '';
|
||||||
|
position: absolute;
|
||||||
|
bottom: 0;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
height: 120px;
|
||||||
|
background: #ffffff;
|
||||||
|
border-radius: 32px 32px 0 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.header-content {
|
||||||
|
position: relative;
|
||||||
|
z-index: 2;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pharmacy-name {
|
||||||
|
color: rgba(255, 255, 255, 0.9);
|
||||||
|
font-size: 15px;
|
||||||
|
font-weight: 500;
|
||||||
|
letter-spacing: -0.2px;
|
||||||
|
margin-bottom: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.header-title {
|
||||||
|
color: #ffffff;
|
||||||
|
font-size: 26px;
|
||||||
|
font-weight: 700;
|
||||||
|
letter-spacing: -0.5px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card {
|
||||||
|
background: #ffffff;
|
||||||
|
border-radius: 20px;
|
||||||
|
padding: 24px;
|
||||||
|
margin: -100px 24px 24px 24px;
|
||||||
|
position: relative;
|
||||||
|
z-index: 3;
|
||||||
|
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.08);
|
||||||
|
}
|
||||||
|
|
||||||
|
.receipt-amount {
|
||||||
|
text-align: center;
|
||||||
|
padding: 24px 0;
|
||||||
|
border-bottom: 1px solid #f1f3f5;
|
||||||
|
margin-bottom: 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.amount-label {
|
||||||
|
color: #868e96;
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 500;
|
||||||
|
margin-bottom: 8px;
|
||||||
|
letter-spacing: -0.2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.amount-value {
|
||||||
|
color: #212529;
|
||||||
|
font-size: 32px;
|
||||||
|
font-weight: 700;
|
||||||
|
letter-spacing: -1px;
|
||||||
|
margin-bottom: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.points-badge {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
background: linear-gradient(135deg, #6366f1 0%, #8b5cf6 100%);
|
||||||
|
color: #ffffff;
|
||||||
|
padding: 10px 20px;
|
||||||
|
border-radius: 100px;
|
||||||
|
font-size: 15px;
|
||||||
|
font-weight: 600;
|
||||||
|
letter-spacing: -0.3px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.points-badge::before {
|
||||||
|
content: '+ ';
|
||||||
|
margin-right: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-section {
|
||||||
|
padding: 8px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.input-group {
|
||||||
|
margin-bottom: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.input-group label {
|
||||||
|
display: block;
|
||||||
|
color: #495057;
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 600;
|
||||||
|
margin-bottom: 10px;
|
||||||
|
letter-spacing: -0.2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.input-wrapper {
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
.input-wrapper input {
|
||||||
|
width: 100%;
|
||||||
|
padding: 16px 18px;
|
||||||
|
border: 2px solid #e9ecef;
|
||||||
|
border-radius: 14px;
|
||||||
|
font-size: 16px;
|
||||||
|
font-weight: 500;
|
||||||
|
transition: all 0.2s ease;
|
||||||
|
letter-spacing: -0.3px;
|
||||||
|
background: #f8f9fa;
|
||||||
|
}
|
||||||
|
|
||||||
|
.input-wrapper input:focus {
|
||||||
|
outline: none;
|
||||||
|
border-color: #6366f1;
|
||||||
|
background: #ffffff;
|
||||||
|
box-shadow: 0 0 0 4px rgba(99, 102, 241, 0.08);
|
||||||
|
}
|
||||||
|
|
||||||
|
.input-wrapper input::placeholder {
|
||||||
|
color: #adb5bd;
|
||||||
|
font-weight: 400;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-submit {
|
||||||
|
width: 100%;
|
||||||
|
padding: 18px;
|
||||||
|
background: linear-gradient(135deg, #6366f1 0%, #8b5cf6 100%);
|
||||||
|
color: #ffffff;
|
||||||
|
border: none;
|
||||||
|
border-radius: 14px;
|
||||||
|
font-size: 17px;
|
||||||
|
font-weight: 700;
|
||||||
|
cursor: pointer;
|
||||||
|
margin-top: 12px;
|
||||||
|
letter-spacing: -0.3px;
|
||||||
|
transition: all 0.2s ease;
|
||||||
|
box-shadow: 0 4px 16px rgba(99, 102, 241, 0.24);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-submit:active {
|
||||||
|
transform: scale(0.98);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-submit:disabled {
|
||||||
|
background: #dee2e6;
|
||||||
|
box-shadow: none;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
|
||||||
|
.alert {
|
||||||
|
margin-top: 16px;
|
||||||
|
padding: 14px 16px;
|
||||||
|
border-radius: 12px;
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 500;
|
||||||
|
display: none;
|
||||||
|
letter-spacing: -0.2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.alert.error {
|
||||||
|
background: #ffe3e3;
|
||||||
|
color: #c92a2a;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 성공 화면 */
|
||||||
|
.success-screen {
|
||||||
|
display: none;
|
||||||
|
padding: 24px;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.success-icon-wrap {
|
||||||
|
width: 96px;
|
||||||
|
height: 96px;
|
||||||
|
margin: 40px auto 24px auto;
|
||||||
|
background: linear-gradient(135deg, #6366f1 0%, #8b5cf6 100%);
|
||||||
|
border-radius: 50%;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
animation: scaleIn 0.5s cubic-bezier(0.175, 0.885, 0.32, 1.275);
|
||||||
|
}
|
||||||
|
|
||||||
|
.success-icon-wrap svg {
|
||||||
|
width: 48px;
|
||||||
|
height: 48px;
|
||||||
|
stroke: #ffffff;
|
||||||
|
stroke-width: 3;
|
||||||
|
stroke-linecap: round;
|
||||||
|
stroke-linejoin: round;
|
||||||
|
fill: none;
|
||||||
|
animation: checkmark 0.6s ease-in-out 0.2s both;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes scaleIn {
|
||||||
|
0% { transform: scale(0); opacity: 0; }
|
||||||
|
100% { transform: scale(1); opacity: 1; }
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes checkmark {
|
||||||
|
0% { stroke-dashoffset: 100; }
|
||||||
|
100% { stroke-dashoffset: 0; }
|
||||||
|
}
|
||||||
|
|
||||||
|
.success-title {
|
||||||
|
color: #212529;
|
||||||
|
font-size: 24px;
|
||||||
|
font-weight: 700;
|
||||||
|
margin-bottom: 12px;
|
||||||
|
letter-spacing: -0.5px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.success-points {
|
||||||
|
color: #6366f1;
|
||||||
|
font-size: 48px;
|
||||||
|
font-weight: 700;
|
||||||
|
margin: 24px 0 16px 0;
|
||||||
|
letter-spacing: -1.5px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.success-balance {
|
||||||
|
color: #868e96;
|
||||||
|
font-size: 15px;
|
||||||
|
font-weight: 500;
|
||||||
|
margin-bottom: 32px;
|
||||||
|
letter-spacing: -0.2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.success-balance strong {
|
||||||
|
color: #495057;
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
|
.button-group {
|
||||||
|
display: flex;
|
||||||
|
gap: 12px;
|
||||||
|
margin-top: 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-secondary {
|
||||||
|
flex: 1;
|
||||||
|
padding: 16px;
|
||||||
|
background: #f8f9fa;
|
||||||
|
color: #495057;
|
||||||
|
border: 2px solid #e9ecef;
|
||||||
|
border-radius: 14px;
|
||||||
|
font-size: 15px;
|
||||||
|
font-weight: 600;
|
||||||
|
text-decoration: none;
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
transition: all 0.2s ease;
|
||||||
|
letter-spacing: -0.2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-secondary:active {
|
||||||
|
transform: scale(0.98);
|
||||||
|
background: #e9ecef;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-primary {
|
||||||
|
flex: 1;
|
||||||
|
padding: 16px;
|
||||||
|
background: linear-gradient(135deg, #6366f1 0%, #8b5cf6 100%);
|
||||||
|
color: #ffffff;
|
||||||
|
border: none;
|
||||||
|
border-radius: 14px;
|
||||||
|
font-size: 15px;
|
||||||
|
font-weight: 600;
|
||||||
|
text-decoration: none;
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
transition: all 0.2s ease;
|
||||||
|
letter-spacing: -0.2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-primary:active {
|
||||||
|
transform: scale(0.98);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 모바일 최적화 */
|
||||||
|
@media (max-width: 480px) {
|
||||||
|
body {
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.app-container {
|
||||||
|
border-radius: 0;
|
||||||
|
min-height: 100vh;
|
||||||
|
}
|
||||||
|
|
||||||
|
.header {
|
||||||
|
padding-top: 48px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="app-container">
|
||||||
|
<div class="header">
|
||||||
|
<div class="header-content">
|
||||||
|
<div class="pharmacy-name">청춘약국</div>
|
||||||
|
<div class="header-title">포인트 적립</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 적립 폼 -->
|
||||||
|
<div id="claimForm">
|
||||||
|
<div class="card">
|
||||||
|
<div class="receipt-amount">
|
||||||
|
<div class="amount-label">구매 금액</div>
|
||||||
|
<div class="amount-value">{{ "{:,}".format(token_info.total_amount) }}원</div>
|
||||||
|
<div class="points-badge">{{ "{:,}".format(token_info.claimable_points) }}P 적립</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<form id="formClaim" class="form-section">
|
||||||
|
<div class="input-group">
|
||||||
|
<label for="phone">전화번호</label>
|
||||||
|
<div class="input-wrapper">
|
||||||
|
<input type="tel" id="phone" name="phone"
|
||||||
|
placeholder="010-0000-0000"
|
||||||
|
pattern="[0-9-]*"
|
||||||
|
autocomplete="tel"
|
||||||
|
required>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="input-group">
|
||||||
|
<label for="name">이름</label>
|
||||||
|
<div class="input-wrapper">
|
||||||
|
<input type="text" id="name" name="name"
|
||||||
|
placeholder="이름을 입력하세요"
|
||||||
|
autocomplete="name"
|
||||||
|
required>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button type="submit" class="btn-submit" id="btnSubmit">
|
||||||
|
포인트 적립하기
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<div class="alert error" id="alertMsg"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 성공 화면 -->
|
||||||
|
<div id="successScreen" class="success-screen">
|
||||||
|
<div class="success-icon-wrap">
|
||||||
|
<svg viewBox="0 0 52 52" style="stroke-dasharray: 100; stroke-dashoffset: 100;">
|
||||||
|
<path d="M14.1 27.2l7.1 7.2 16.7-16.8"/>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
<div class="success-title">적립 완료!</div>
|
||||||
|
<div class="success-points" id="successPoints">0P</div>
|
||||||
|
<div class="success-balance">
|
||||||
|
총 포인트 <strong id="successBalance">0P</strong>
|
||||||
|
</div>
|
||||||
|
<div class="button-group">
|
||||||
|
<a href="/" class="btn-secondary">홈으로</a>
|
||||||
|
<a href="#" class="btn-primary" id="btnMyPage">내역 보기</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
const tokenInfo = {
|
||||||
|
transaction_id: '{{ token_info.transaction_id }}',
|
||||||
|
nonce: '{{ request.args.get("t").split(":")[1] }}'
|
||||||
|
};
|
||||||
|
|
||||||
|
const form = document.getElementById('formClaim');
|
||||||
|
const btnSubmit = document.getElementById('btnSubmit');
|
||||||
|
const alertMsg = document.getElementById('alertMsg');
|
||||||
|
const claimFormDiv = document.getElementById('claimForm');
|
||||||
|
const successScreen = document.getElementById('successScreen');
|
||||||
|
|
||||||
|
// 전화번호 자동 하이픈
|
||||||
|
const phoneInput = document.getElementById('phone');
|
||||||
|
phoneInput.addEventListener('input', function(e) {
|
||||||
|
let value = e.target.value.replace(/[^0-9]/g, '');
|
||||||
|
|
||||||
|
if (value.length <= 3) {
|
||||||
|
e.target.value = value;
|
||||||
|
} else if (value.length <= 7) {
|
||||||
|
e.target.value = value.slice(0, 3) + '-' + value.slice(3);
|
||||||
|
} else {
|
||||||
|
e.target.value = value.slice(0, 3) + '-' + value.slice(3, 7) + '-' + value.slice(7, 11);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// 폼 제출
|
||||||
|
form.addEventListener('submit', async function(e) {
|
||||||
|
e.preventDefault();
|
||||||
|
|
||||||
|
const phone = document.getElementById('phone').value.trim();
|
||||||
|
const name = document.getElementById('name').value.trim();
|
||||||
|
|
||||||
|
if (!phone || !name) {
|
||||||
|
showAlert('전화번호와 이름을 모두 입력해주세요.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
btnSubmit.disabled = true;
|
||||||
|
btnSubmit.textContent = '처리 중...';
|
||||||
|
alertMsg.style.display = 'none';
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch('/api/claim', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json'
|
||||||
|
},
|
||||||
|
body: JSON.stringify({
|
||||||
|
transaction_id: tokenInfo.transaction_id,
|
||||||
|
nonce: tokenInfo.nonce,
|
||||||
|
phone: phone,
|
||||||
|
name: name
|
||||||
|
})
|
||||||
|
});
|
||||||
|
|
||||||
|
const data = await response.json();
|
||||||
|
|
||||||
|
if (data.success) {
|
||||||
|
showSuccess(data.points, data.balance, phone);
|
||||||
|
} else {
|
||||||
|
showAlert(data.message);
|
||||||
|
btnSubmit.disabled = false;
|
||||||
|
btnSubmit.textContent = '포인트 적립하기';
|
||||||
|
}
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
showAlert('네트워크 오류가 발생했습니다.');
|
||||||
|
btnSubmit.disabled = false;
|
||||||
|
btnSubmit.textContent = '포인트 적립하기';
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
function showAlert(msg) {
|
||||||
|
alertMsg.textContent = msg;
|
||||||
|
alertMsg.style.display = 'block';
|
||||||
|
|
||||||
|
setTimeout(() => {
|
||||||
|
alertMsg.style.display = 'none';
|
||||||
|
}, 5000);
|
||||||
|
}
|
||||||
|
|
||||||
|
function showSuccess(points, balance, phone) {
|
||||||
|
claimFormDiv.style.display = 'none';
|
||||||
|
|
||||||
|
document.getElementById('successPoints').textContent = points.toLocaleString() + 'P';
|
||||||
|
document.getElementById('successBalance').textContent = balance.toLocaleString() + 'P';
|
||||||
|
document.getElementById('btnMyPage').href = '/my-page?phone=' + encodeURIComponent(phone);
|
||||||
|
|
||||||
|
successScreen.style.display = 'block';
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
94
backend/templates/error.html
Normal file
94
backend/templates/error.html
Normal file
@ -0,0 +1,94 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="ko">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no">
|
||||||
|
<title>오류 - 청춘약국</title>
|
||||||
|
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||||
|
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||||
|
<link href="https://fonts.googleapis.com/css2?family=Noto+Sans+KR:wght@400;500;700&display=swap" rel="stylesheet">
|
||||||
|
<style>
|
||||||
|
* {
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
font-family: 'Noto Sans KR', -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
|
||||||
|
background: #f5f7fa;
|
||||||
|
min-height: 100vh;
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
align-items: center;
|
||||||
|
padding: 16px;
|
||||||
|
-webkit-font-smoothing: antialiased;
|
||||||
|
}
|
||||||
|
|
||||||
|
.error-container {
|
||||||
|
background: #ffffff;
|
||||||
|
border-radius: 24px;
|
||||||
|
padding: 48px 32px;
|
||||||
|
max-width: 420px;
|
||||||
|
width: 100%;
|
||||||
|
text-align: center;
|
||||||
|
box-shadow: 0 4px 24px rgba(0, 0, 0, 0.06);
|
||||||
|
}
|
||||||
|
|
||||||
|
.error-icon {
|
||||||
|
width: 80px;
|
||||||
|
height: 80px;
|
||||||
|
margin: 0 auto 24px auto;
|
||||||
|
background: linear-gradient(135deg, #f03e3e 0%, #d6336c 100%);
|
||||||
|
border-radius: 50%;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
font-size: 40px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.error-title {
|
||||||
|
color: #212529;
|
||||||
|
font-size: 24px;
|
||||||
|
font-weight: 700;
|
||||||
|
margin-bottom: 16px;
|
||||||
|
letter-spacing: -0.5px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.error-message {
|
||||||
|
color: #868e96;
|
||||||
|
font-size: 15px;
|
||||||
|
font-weight: 500;
|
||||||
|
line-height: 1.6;
|
||||||
|
margin-bottom: 32px;
|
||||||
|
letter-spacing: -0.2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-home {
|
||||||
|
display: inline-block;
|
||||||
|
padding: 16px 32px;
|
||||||
|
background: linear-gradient(135deg, #6366f1 0%, #8b5cf6 100%);
|
||||||
|
color: #ffffff;
|
||||||
|
text-decoration: none;
|
||||||
|
border-radius: 14px;
|
||||||
|
font-size: 16px;
|
||||||
|
font-weight: 600;
|
||||||
|
letter-spacing: -0.2px;
|
||||||
|
transition: all 0.2s ease;
|
||||||
|
box-shadow: 0 4px 16px rgba(99, 102, 241, 0.24);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-home:active {
|
||||||
|
transform: scale(0.98);
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="error-container">
|
||||||
|
<div class="error-icon">⚠️</div>
|
||||||
|
<div class="error-title">문제가 발생했어요</div>
|
||||||
|
<div class="error-message">{{ message }}</div>
|
||||||
|
<a href="/" class="btn-home">홈으로 이동</a>
|
||||||
|
</div>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
262
backend/templates/my_page.html
Normal file
262
backend/templates/my_page.html
Normal file
@ -0,0 +1,262 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="ko">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no">
|
||||||
|
<title>마이페이지 - 청춘약국</title>
|
||||||
|
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||||
|
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||||
|
<link href="https://fonts.googleapis.com/css2?family=Noto+Sans+KR:wght@400;500;700&display=swap" rel="stylesheet">
|
||||||
|
<style>
|
||||||
|
* {
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
font-family: 'Noto Sans KR', -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
|
||||||
|
background: #f5f7fa;
|
||||||
|
min-height: 100vh;
|
||||||
|
-webkit-font-smoothing: antialiased;
|
||||||
|
}
|
||||||
|
|
||||||
|
.app-container {
|
||||||
|
background: #ffffff;
|
||||||
|
min-height: 100vh;
|
||||||
|
max-width: 420px;
|
||||||
|
margin: 0 auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.header {
|
||||||
|
background: linear-gradient(135deg, #6366f1 0%, #8b5cf6 100%);
|
||||||
|
padding: 48px 24px 32px 24px;
|
||||||
|
color: #ffffff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.header-top {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
margin-bottom: 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.header-title {
|
||||||
|
font-size: 20px;
|
||||||
|
font-weight: 700;
|
||||||
|
letter-spacing: -0.3px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-logout {
|
||||||
|
color: rgba(255, 255, 255, 0.9);
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 500;
|
||||||
|
text-decoration: none;
|
||||||
|
letter-spacing: -0.2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.user-info {
|
||||||
|
text-align: center;
|
||||||
|
padding: 20px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.user-name {
|
||||||
|
font-size: 24px;
|
||||||
|
font-weight: 700;
|
||||||
|
margin-bottom: 8px;
|
||||||
|
letter-spacing: -0.5px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.user-phone {
|
||||||
|
font-size: 15px;
|
||||||
|
opacity: 0.9;
|
||||||
|
font-weight: 500;
|
||||||
|
letter-spacing: -0.2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.balance-card {
|
||||||
|
background: rgba(255, 255, 255, 0.95);
|
||||||
|
border-radius: 20px;
|
||||||
|
padding: 32px 24px;
|
||||||
|
margin: -40px 24px 24px 24px;
|
||||||
|
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.08);
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.balance-label {
|
||||||
|
color: #868e96;
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 500;
|
||||||
|
margin-bottom: 12px;
|
||||||
|
letter-spacing: -0.2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.balance-amount {
|
||||||
|
color: #6366f1;
|
||||||
|
font-size: 48px;
|
||||||
|
font-weight: 700;
|
||||||
|
letter-spacing: -1.5px;
|
||||||
|
margin-bottom: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.balance-desc {
|
||||||
|
color: #868e96;
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 500;
|
||||||
|
letter-spacing: -0.2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.section {
|
||||||
|
padding: 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.section-title {
|
||||||
|
color: #212529;
|
||||||
|
font-size: 18px;
|
||||||
|
font-weight: 700;
|
||||||
|
margin-bottom: 16px;
|
||||||
|
letter-spacing: -0.3px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.transaction-list {
|
||||||
|
list-style: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.transaction-item {
|
||||||
|
background: #ffffff;
|
||||||
|
border: 1px solid #e9ecef;
|
||||||
|
border-radius: 16px;
|
||||||
|
padding: 20px;
|
||||||
|
margin-bottom: 12px;
|
||||||
|
transition: all 0.2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.transaction-item:active {
|
||||||
|
transform: scale(0.98);
|
||||||
|
background: #f8f9fa;
|
||||||
|
}
|
||||||
|
|
||||||
|
.transaction-header {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
margin-bottom: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.transaction-reason {
|
||||||
|
color: #495057;
|
||||||
|
font-size: 15px;
|
||||||
|
font-weight: 600;
|
||||||
|
letter-spacing: -0.2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.transaction-points {
|
||||||
|
color: #6366f1;
|
||||||
|
font-size: 18px;
|
||||||
|
font-weight: 700;
|
||||||
|
letter-spacing: -0.3px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.transaction-points.positive::before {
|
||||||
|
content: '+';
|
||||||
|
}
|
||||||
|
|
||||||
|
.transaction-desc {
|
||||||
|
color: #868e96;
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 500;
|
||||||
|
margin-bottom: 8px;
|
||||||
|
letter-spacing: -0.2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.transaction-date {
|
||||||
|
color: #adb5bd;
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 500;
|
||||||
|
letter-spacing: -0.2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.empty-state {
|
||||||
|
text-align: center;
|
||||||
|
padding: 60px 20px;
|
||||||
|
color: #868e96;
|
||||||
|
}
|
||||||
|
|
||||||
|
.empty-icon {
|
||||||
|
font-size: 64px;
|
||||||
|
margin-bottom: 16px;
|
||||||
|
opacity: 0.5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.empty-text {
|
||||||
|
font-size: 15px;
|
||||||
|
font-weight: 500;
|
||||||
|
letter-spacing: -0.2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 모바일 최적화 */
|
||||||
|
@media (max-width: 480px) {
|
||||||
|
.header {
|
||||||
|
padding-top: 60px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="app-container">
|
||||||
|
<div class="header">
|
||||||
|
<div class="header-top">
|
||||||
|
<div class="header-title">마이페이지</div>
|
||||||
|
<a href="/my-page" class="btn-logout">다른 번호로 조회</a>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="user-info">
|
||||||
|
<div class="user-name">{{ user.nickname }}님</div>
|
||||||
|
<div class="user-phone">{{ user.phone[:3] }}-{{ user.phone[3:7] }}-{{ user.phone[7:] if user.phone|length > 7 else '' }}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="balance-card">
|
||||||
|
<div class="balance-label">보유 포인트</div>
|
||||||
|
<div class="balance-amount">{{ "{:,}".format(user.mileage_balance) }}P</div>
|
||||||
|
<div class="balance-desc">약국에서 1P = 1원으로 사용 가능</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="section">
|
||||||
|
<div class="section-title">적립 내역</div>
|
||||||
|
|
||||||
|
{% if transactions %}
|
||||||
|
<ul class="transaction-list">
|
||||||
|
{% for tx in transactions %}
|
||||||
|
<li class="transaction-item">
|
||||||
|
<div class="transaction-header">
|
||||||
|
<div class="transaction-reason">
|
||||||
|
{% if tx.reason == 'CLAIM' %}
|
||||||
|
영수증 적립
|
||||||
|
{% elif tx.reason == 'USE' %}
|
||||||
|
포인트 사용
|
||||||
|
{% else %}
|
||||||
|
{{ tx.reason }}
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
<div class="transaction-points {% if tx.points > 0 %}positive{% endif %}">
|
||||||
|
{{ "{:,}".format(tx.points) }}P
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% if tx.description %}
|
||||||
|
<div class="transaction-desc">{{ tx.description }}</div>
|
||||||
|
{% endif %}
|
||||||
|
<div class="transaction-date">{{ tx.created_at[:16].replace('T', ' ') }}</div>
|
||||||
|
</li>
|
||||||
|
{% endfor %}
|
||||||
|
</ul>
|
||||||
|
{% else %}
|
||||||
|
<div class="empty-state">
|
||||||
|
<div class="empty-icon">📭</div>
|
||||||
|
<div class="empty-text">아직 적립 내역이 없습니다</div>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
183
backend/templates/my_page_login.html
Normal file
183
backend/templates/my_page_login.html
Normal file
@ -0,0 +1,183 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="ko">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no">
|
||||||
|
<title>마이페이지 - 청춘약국</title>
|
||||||
|
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||||
|
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||||
|
<link href="https://fonts.googleapis.com/css2?family=Noto+Sans+KR:wght@400;500;700&display=swap" rel="stylesheet">
|
||||||
|
<style>
|
||||||
|
* {
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
font-family: 'Noto Sans KR', -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
|
||||||
|
background: #f5f7fa;
|
||||||
|
min-height: 100vh;
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
align-items: center;
|
||||||
|
padding: 16px;
|
||||||
|
-webkit-font-smoothing: antialiased;
|
||||||
|
}
|
||||||
|
|
||||||
|
.login-container {
|
||||||
|
background: #ffffff;
|
||||||
|
border-radius: 24px;
|
||||||
|
padding: 48px 32px;
|
||||||
|
max-width: 420px;
|
||||||
|
width: 100%;
|
||||||
|
box-shadow: 0 4px 24px rgba(0, 0, 0, 0.06);
|
||||||
|
}
|
||||||
|
|
||||||
|
.header {
|
||||||
|
text-align: center;
|
||||||
|
margin-bottom: 40px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.logo {
|
||||||
|
width: 64px;
|
||||||
|
height: 64px;
|
||||||
|
margin: 0 auto 20px auto;
|
||||||
|
background: linear-gradient(135deg, #6366f1 0%, #8b5cf6 100%);
|
||||||
|
border-radius: 20px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
font-size: 32px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.header-title {
|
||||||
|
color: #212529;
|
||||||
|
font-size: 24px;
|
||||||
|
font-weight: 700;
|
||||||
|
margin-bottom: 8px;
|
||||||
|
letter-spacing: -0.5px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.header-subtitle {
|
||||||
|
color: #868e96;
|
||||||
|
font-size: 15px;
|
||||||
|
font-weight: 500;
|
||||||
|
letter-spacing: -0.2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-group {
|
||||||
|
margin-bottom: 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-group label {
|
||||||
|
display: block;
|
||||||
|
color: #495057;
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 600;
|
||||||
|
margin-bottom: 10px;
|
||||||
|
letter-spacing: -0.2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-group input {
|
||||||
|
width: 100%;
|
||||||
|
padding: 16px 18px;
|
||||||
|
border: 2px solid #e9ecef;
|
||||||
|
border-radius: 14px;
|
||||||
|
font-size: 16px;
|
||||||
|
font-weight: 500;
|
||||||
|
transition: all 0.2s ease;
|
||||||
|
letter-spacing: -0.3px;
|
||||||
|
background: #f8f9fa;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-group input:focus {
|
||||||
|
outline: none;
|
||||||
|
border-color: #6366f1;
|
||||||
|
background: #ffffff;
|
||||||
|
box-shadow: 0 0 0 4px rgba(99, 102, 241, 0.08);
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-group input::placeholder {
|
||||||
|
color: #adb5bd;
|
||||||
|
font-weight: 400;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-submit {
|
||||||
|
width: 100%;
|
||||||
|
padding: 18px;
|
||||||
|
background: linear-gradient(135deg, #6366f1 0%, #8b5cf6 100%);
|
||||||
|
color: #ffffff;
|
||||||
|
border: none;
|
||||||
|
border-radius: 14px;
|
||||||
|
font-size: 17px;
|
||||||
|
font-weight: 700;
|
||||||
|
cursor: pointer;
|
||||||
|
letter-spacing: -0.3px;
|
||||||
|
transition: all 0.2s ease;
|
||||||
|
box-shadow: 0 4px 16px rgba(99, 102, 241, 0.24);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-submit:active {
|
||||||
|
transform: scale(0.98);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-back {
|
||||||
|
display: block;
|
||||||
|
text-align: center;
|
||||||
|
margin-top: 20px;
|
||||||
|
color: #6366f1;
|
||||||
|
text-decoration: none;
|
||||||
|
font-size: 15px;
|
||||||
|
font-weight: 500;
|
||||||
|
letter-spacing: -0.2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-back:active {
|
||||||
|
color: #8b5cf6;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="login-container">
|
||||||
|
<div class="header">
|
||||||
|
<div class="logo">📱</div>
|
||||||
|
<div class="header-title">마이페이지</div>
|
||||||
|
<div class="header-subtitle">전화번호로 포인트 조회</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<form method="GET" action="/my-page">
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="phone">전화번호</label>
|
||||||
|
<input type="tel" id="phone" name="phone"
|
||||||
|
placeholder="010-0000-0000"
|
||||||
|
pattern="[0-9-]*"
|
||||||
|
autocomplete="tel"
|
||||||
|
required>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button type="submit" class="btn-submit">
|
||||||
|
조회하기
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<a href="/" class="btn-back">← 홈으로</a>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
// 전화번호 자동 하이픈
|
||||||
|
const phoneInput = document.getElementById('phone');
|
||||||
|
phoneInput.addEventListener('input', function(e) {
|
||||||
|
let value = e.target.value.replace(/[^0-9]/g, '');
|
||||||
|
|
||||||
|
if (value.length <= 3) {
|
||||||
|
e.target.value = value;
|
||||||
|
} else if (value.length <= 7) {
|
||||||
|
e.target.value = value.slice(0, 3) + '-' + value.slice(3);
|
||||||
|
} else {
|
||||||
|
e.target.value = value.slice(0, 3) + '-' + value.slice(3, 7) + '-' + value.slice(7, 11);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
98
backend/test_integration.py
Normal file
98
backend/test_integration.py
Normal file
@ -0,0 +1,98 @@
|
|||||||
|
"""
|
||||||
|
통합 테스트: QR 라벨 전체 흐름
|
||||||
|
토큰 생성 → DB 저장 → QR 라벨 이미지 생성
|
||||||
|
"""
|
||||||
|
|
||||||
|
import sys
|
||||||
|
import os
|
||||||
|
from datetime import datetime
|
||||||
|
|
||||||
|
# Path setup
|
||||||
|
sys.path.insert(0, os.path.join(os.path.dirname(__file__)))
|
||||||
|
|
||||||
|
from utils.qr_token_generator import generate_claim_token, save_token_to_db
|
||||||
|
from utils.qr_label_printer import print_qr_label
|
||||||
|
|
||||||
|
def test_full_flow():
|
||||||
|
"""전체 흐름 테스트"""
|
||||||
|
|
||||||
|
# 1. 테스트 데이터 (새로운 거래 ID)
|
||||||
|
test_tx_id = datetime.now().strftime("TEST%Y%m%d%H%M%S")
|
||||||
|
test_amount = 75000.0
|
||||||
|
test_time = datetime.now()
|
||||||
|
|
||||||
|
print("=" * 80)
|
||||||
|
print("QR 라벨 통합 테스트")
|
||||||
|
print("=" * 80)
|
||||||
|
print(f"거래 ID: {test_tx_id}")
|
||||||
|
print(f"판매 금액: {test_amount:,}원")
|
||||||
|
print()
|
||||||
|
|
||||||
|
# 2. 토큰 생성
|
||||||
|
print("[1/3] Claim Token 생성...")
|
||||||
|
token_info = generate_claim_token(test_tx_id, test_amount)
|
||||||
|
|
||||||
|
print(f" [OK] 토큰 원문: {token_info['token_raw'][:50]}...")
|
||||||
|
print(f" [OK] 토큰 해시: {token_info['token_hash'][:32]}...")
|
||||||
|
print(f" [OK] QR URL: {token_info['qr_url']}")
|
||||||
|
print(f" [OK] URL 길이: {len(token_info['qr_url'])} 문자")
|
||||||
|
print(f" [OK] 적립 포인트: {token_info['claimable_points']}P")
|
||||||
|
print()
|
||||||
|
|
||||||
|
# 3. DB 저장
|
||||||
|
print("[2/3] SQLite DB 저장...")
|
||||||
|
success, error = save_token_to_db(
|
||||||
|
test_tx_id,
|
||||||
|
token_info['token_hash'],
|
||||||
|
test_amount,
|
||||||
|
token_info['claimable_points'],
|
||||||
|
token_info['expires_at'],
|
||||||
|
token_info['pharmacy_id']
|
||||||
|
)
|
||||||
|
|
||||||
|
if not success:
|
||||||
|
print(f" [ERROR] DB 저장 실패: {error}")
|
||||||
|
return False
|
||||||
|
|
||||||
|
print(f" [OK] DB 저장 성공")
|
||||||
|
print()
|
||||||
|
|
||||||
|
# 4. QR 라벨 생성 (미리보기 모드)
|
||||||
|
print("[3/3] QR 라벨 이미지 생성...")
|
||||||
|
success, image_path = print_qr_label(
|
||||||
|
token_info['qr_url'],
|
||||||
|
test_tx_id,
|
||||||
|
test_amount,
|
||||||
|
token_info['claimable_points'],
|
||||||
|
test_time,
|
||||||
|
preview_mode=True
|
||||||
|
)
|
||||||
|
|
||||||
|
if not success:
|
||||||
|
print(f" [ERROR] 이미지 생성 실패")
|
||||||
|
return False
|
||||||
|
|
||||||
|
print(f" [OK] 이미지 저장: {image_path}")
|
||||||
|
print()
|
||||||
|
|
||||||
|
# 5. 결과 요약
|
||||||
|
print("=" * 80)
|
||||||
|
print("[SUCCESS] 통합 테스트 성공!")
|
||||||
|
print("=" * 80)
|
||||||
|
print(f"QR URL: {token_info['qr_url']}")
|
||||||
|
print(f"이미지 파일: {image_path}")
|
||||||
|
print(f"\n다음 명령으로 확인:")
|
||||||
|
print(f" start {image_path}")
|
||||||
|
print("=" * 80)
|
||||||
|
|
||||||
|
return True
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
try:
|
||||||
|
success = test_full_flow()
|
||||||
|
sys.exit(0 if success else 1)
|
||||||
|
except Exception as e:
|
||||||
|
print(f"\n[ERROR] 테스트 실패: {e}")
|
||||||
|
import traceback
|
||||||
|
traceback.print_exc()
|
||||||
|
sys.exit(1)
|
||||||
288
backend/utils/qr_label_printer.py
Normal file
288
backend/utils/qr_label_printer.py
Normal file
@ -0,0 +1,288 @@
|
|||||||
|
"""
|
||||||
|
QR 영수증 라벨 인쇄 모듈
|
||||||
|
Brother QL-810W 프린터용 29mm 가로형 라벨
|
||||||
|
"""
|
||||||
|
|
||||||
|
from PIL import Image, ImageDraw, ImageFont
|
||||||
|
from brother_ql.raster import BrotherQLRaster
|
||||||
|
from brother_ql.conversion import convert
|
||||||
|
from brother_ql.backends.helpers import send
|
||||||
|
import qrcode
|
||||||
|
import os
|
||||||
|
from datetime import datetime
|
||||||
|
import logging
|
||||||
|
|
||||||
|
# 프린터 설정 (barcode_print.py와 동일)
|
||||||
|
PRINTER_IP = "192.168.0.168"
|
||||||
|
PRINTER_PORT = 9100
|
||||||
|
PRINTER_MODEL = "QL-810W"
|
||||||
|
LABEL_TYPE = "29"
|
||||||
|
|
||||||
|
# 로깅 설정
|
||||||
|
logging.basicConfig(level=logging.INFO, format='[QR_LABEL] %(levelname)s: %(message)s')
|
||||||
|
|
||||||
|
|
||||||
|
def get_font_path():
|
||||||
|
"""
|
||||||
|
폰트 파일 경로 자동 감지 (폴백 지원)
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
str: 폰트 파일 경로 또는 None (기본 폰트 사용)
|
||||||
|
"""
|
||||||
|
candidates = [
|
||||||
|
os.path.join(os.path.dirname(__file__), "..", "samples", "fonts", "malgunbd.ttf"),
|
||||||
|
"C:\\Windows\\Fonts\\malgunbd.ttf", # Windows 기본 경로
|
||||||
|
"C:\\Windows\\Fonts\\malgun.ttf", # 볼드 아닌 버전
|
||||||
|
"/usr/share/fonts/truetype/malgun.ttf", # Linux
|
||||||
|
"malgun.ttf", # 시스템 폰트
|
||||||
|
]
|
||||||
|
|
||||||
|
for path in candidates:
|
||||||
|
if os.path.exists(path):
|
||||||
|
logging.info(f"폰트 파일 사용: {path}")
|
||||||
|
return path
|
||||||
|
|
||||||
|
logging.warning("폰트 파일을 찾을 수 없습니다. 기본 폰트 사용.")
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def create_qr_receipt_label(qr_url, transaction_id, total_amount, claimable_points,
|
||||||
|
transaction_time):
|
||||||
|
"""
|
||||||
|
QR 영수증 라벨 이미지 생성
|
||||||
|
|
||||||
|
Args:
|
||||||
|
qr_url (str): QR 코드 URL (token_raw 포함)
|
||||||
|
transaction_id (str): 거래 번호
|
||||||
|
total_amount (float): 총 판매 금액
|
||||||
|
claimable_points (int): 적립 가능 포인트
|
||||||
|
transaction_time (datetime): 거래 시간
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
PIL.Image: 생성된 라벨 이미지 (800x306px, mode='1')
|
||||||
|
|
||||||
|
레이아웃 (가로형):
|
||||||
|
┌────────────────────────────────────────────────────────────┐
|
||||||
|
│ │
|
||||||
|
│ [청춘약국] [QR CODE] │
|
||||||
|
│ 2025-10-24 14:30 120x120px │
|
||||||
|
│ 거래: 20251024000042 │
|
||||||
|
│ │
|
||||||
|
│ 결제금액: 50,000원 │
|
||||||
|
│ 적립예정: 1,500P │
|
||||||
|
│ │
|
||||||
|
│ QR 촬영하고 포인트 받으세요! │
|
||||||
|
│ │
|
||||||
|
└────────────────────────────────────────────────────────────┘
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
# 1. 캔버스 생성 (가로형)
|
||||||
|
width = 800
|
||||||
|
height = 306
|
||||||
|
img = Image.new('1', (width, height), 1) # 흰색 배경
|
||||||
|
draw = ImageDraw.Draw(img)
|
||||||
|
|
||||||
|
# 2. 폰트 로드
|
||||||
|
font_path = get_font_path()
|
||||||
|
|
||||||
|
try:
|
||||||
|
if font_path:
|
||||||
|
font_title = ImageFont.truetype(font_path, 48) # 약국명
|
||||||
|
font_info = ImageFont.truetype(font_path, 32) # 거래 정보
|
||||||
|
font_amount = ImageFont.truetype(font_path, 40) # 금액 (크게)
|
||||||
|
font_points = ImageFont.truetype(font_path, 36) # 포인트 (강조)
|
||||||
|
font_small = ImageFont.truetype(font_path, 28) # 안내 문구
|
||||||
|
else:
|
||||||
|
raise IOError("폰트 없음")
|
||||||
|
except (IOError, OSError):
|
||||||
|
logging.warning("TrueType 폰트 로드 실패. 기본 폰트 사용.")
|
||||||
|
font_title = ImageFont.load_default()
|
||||||
|
font_info = ImageFont.load_default()
|
||||||
|
font_amount = ImageFont.load_default()
|
||||||
|
font_points = ImageFont.load_default()
|
||||||
|
font_small = ImageFont.load_default()
|
||||||
|
|
||||||
|
# 3. QR 코드 생성 (우측 상단) - 크기 및 해상도 개선
|
||||||
|
qr = qrcode.QRCode(
|
||||||
|
version=1,
|
||||||
|
error_correction=qrcode.constants.ERROR_CORRECT_H, # 최고 레벨 (30% 복원)
|
||||||
|
box_size=8, # 기존 4 -> 8로 증가 (더 선명)
|
||||||
|
border=2, # 테두리 2칸 (인식률 향상)
|
||||||
|
)
|
||||||
|
qr.add_data(qr_url)
|
||||||
|
qr.make(fit=True)
|
||||||
|
qr_img = qr.make_image(fill_color="black", back_color="white")
|
||||||
|
|
||||||
|
# QR 코드 크기 및 위치 (기존 120 -> 200으로 증가)
|
||||||
|
qr_size = 200 # 크기 대폭 증가
|
||||||
|
qr_img = qr_img.resize((qr_size, qr_size), Image.LANCZOS)
|
||||||
|
qr_x = width - qr_size - 15 # 우측 여백 15px
|
||||||
|
qr_y = 10 # 상단 여백 10px
|
||||||
|
|
||||||
|
# QR 코드 붙이기
|
||||||
|
if qr_img.mode != '1':
|
||||||
|
qr_img = qr_img.convert('1')
|
||||||
|
img.paste(qr_img, (qr_x, qr_y))
|
||||||
|
|
||||||
|
# 4. 좌측 텍스트 영역 (y 위치 추적)
|
||||||
|
x_margin = 20
|
||||||
|
y = 20
|
||||||
|
|
||||||
|
# 약국명 (굵게)
|
||||||
|
pharmacy_text = "청춘약국"
|
||||||
|
for offset in [(0, 0), (1, 0), (2, 0), (0, 1), (1, 1), (2, 1)]:
|
||||||
|
draw.text((x_margin + offset[0], y + offset[1]), pharmacy_text,
|
||||||
|
font=font_title, fill=0)
|
||||||
|
y += 55
|
||||||
|
|
||||||
|
# 거래 시간
|
||||||
|
time_text = transaction_time.strftime('%Y-%m-%d %H:%M')
|
||||||
|
draw.text((x_margin, y), time_text, font=font_info, fill=0)
|
||||||
|
y += 40
|
||||||
|
|
||||||
|
# 거래 번호
|
||||||
|
tx_text = f"거래: {transaction_id}"
|
||||||
|
draw.text((x_margin, y), tx_text, font=font_info, fill=0)
|
||||||
|
y += 50
|
||||||
|
|
||||||
|
# 결제 금액 (강조)
|
||||||
|
amount_text = f"결제금액: {int(total_amount):,}원"
|
||||||
|
for offset in [(0, 0), (1, 0), (0, 1), (1, 1)]:
|
||||||
|
draw.text((x_margin + offset[0], y + offset[1]), amount_text,
|
||||||
|
font=font_amount, fill=0)
|
||||||
|
y += 50
|
||||||
|
|
||||||
|
# 적립 포인트 (굵게)
|
||||||
|
points_text = f"적립예정: {claimable_points:,}P"
|
||||||
|
for offset in [(0, 0), (1, 0), (0, 1), (1, 1)]:
|
||||||
|
draw.text((x_margin + offset[0], y + offset[1]), points_text,
|
||||||
|
font=font_points, fill=0)
|
||||||
|
y += 55
|
||||||
|
|
||||||
|
# 안내 문구
|
||||||
|
guide_text = "QR 촬영하고 포인트 받으세요!"
|
||||||
|
draw.text((x_margin, y), guide_text, font=font_small, fill=0)
|
||||||
|
|
||||||
|
# 5. 테두리 (가위선 스타일)
|
||||||
|
for i in range(2):
|
||||||
|
draw.rectangle([5 + i, 5 + i, width - 5 - i, height - 5 - i], outline=0)
|
||||||
|
|
||||||
|
logging.info(f"QR 라벨 이미지 생성 완료: {transaction_id}")
|
||||||
|
return img
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logging.error(f"QR 라벨 이미지 생성 실패: {e}")
|
||||||
|
raise
|
||||||
|
|
||||||
|
|
||||||
|
def print_qr_label(qr_url, transaction_id, total_amount, claimable_points,
|
||||||
|
transaction_time, preview_mode=False):
|
||||||
|
"""
|
||||||
|
QR 라벨 출력 또는 미리보기
|
||||||
|
|
||||||
|
Args:
|
||||||
|
qr_url (str): QR 코드 URL
|
||||||
|
transaction_id (str): 거래 번호
|
||||||
|
total_amount (float): 판매 금액
|
||||||
|
claimable_points (int): 적립 포인트
|
||||||
|
transaction_time (datetime): 거래 시간
|
||||||
|
preview_mode (bool): True = 미리보기, False = 인쇄
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
preview_mode=True: (성공 여부, 이미지 파일 경로)
|
||||||
|
preview_mode=False: 성공 여부 (bool)
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
logging.info(f"QR 라벨 {'미리보기' if preview_mode else '출력'} 시작: {transaction_id}")
|
||||||
|
|
||||||
|
# 1. 라벨 이미지 생성
|
||||||
|
label_image = create_qr_receipt_label(
|
||||||
|
qr_url, transaction_id, total_amount,
|
||||||
|
claimable_points, transaction_time
|
||||||
|
)
|
||||||
|
|
||||||
|
# 2. 미리보기 모드
|
||||||
|
if preview_mode:
|
||||||
|
# temp 디렉터리 생성
|
||||||
|
temp_dir = os.path.join(os.path.dirname(__file__), "..", "samples", "temp")
|
||||||
|
os.makedirs(temp_dir, exist_ok=True)
|
||||||
|
|
||||||
|
# 파일명 생성
|
||||||
|
timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
|
||||||
|
filename = f"qr_receipt_{transaction_id}_{timestamp}.png"
|
||||||
|
file_path = os.path.join(temp_dir, filename)
|
||||||
|
|
||||||
|
# PNG로 저장
|
||||||
|
label_image.save(file_path, "PNG")
|
||||||
|
logging.info(f"미리보기 이미지 저장: {file_path}")
|
||||||
|
|
||||||
|
return True, file_path
|
||||||
|
|
||||||
|
# 3. 실제 인쇄 모드
|
||||||
|
else:
|
||||||
|
# 이미지 90도 회전 (Brother QL은 세로 기준)
|
||||||
|
label_image_rotated = label_image.rotate(90, expand=True)
|
||||||
|
|
||||||
|
# Brother QL Raster 변환
|
||||||
|
qlr = BrotherQLRaster(PRINTER_MODEL)
|
||||||
|
instructions = convert(
|
||||||
|
qlr=qlr,
|
||||||
|
images=[label_image_rotated],
|
||||||
|
label=LABEL_TYPE,
|
||||||
|
rotate="0",
|
||||||
|
threshold=70.0,
|
||||||
|
dither=False,
|
||||||
|
compress=False,
|
||||||
|
red=False,
|
||||||
|
dpi_600=False,
|
||||||
|
hq=True,
|
||||||
|
cut=True
|
||||||
|
)
|
||||||
|
|
||||||
|
# 프린터로 전송
|
||||||
|
printer_identifier = f"tcp://{PRINTER_IP}:{PRINTER_PORT}"
|
||||||
|
send(instructions, printer_identifier=printer_identifier)
|
||||||
|
|
||||||
|
logging.info(f"QR 라벨 출력 완료: {transaction_id}")
|
||||||
|
return True
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logging.error(f"QR 라벨 {'미리보기' if preview_mode else '출력'} 실패: {e}")
|
||||||
|
if preview_mode:
|
||||||
|
return False, None
|
||||||
|
else:
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
# 테스트 코드
|
||||||
|
if __name__ == "__main__":
|
||||||
|
# 테스트 데이터
|
||||||
|
test_qr_url = "https://pharmacy.example.com/claim?t=20251024000042:abc123:2025-10-24T14:30:00"
|
||||||
|
test_tx_id = "20251024000042"
|
||||||
|
test_amount = 50000.0
|
||||||
|
test_points = 1500
|
||||||
|
test_time = datetime.now()
|
||||||
|
|
||||||
|
print("=" * 80)
|
||||||
|
print("QR 라벨 생성 테스트")
|
||||||
|
print("=" * 80)
|
||||||
|
print(f"거래 ID: {test_tx_id}")
|
||||||
|
print(f"금액: {test_amount:,}원")
|
||||||
|
print(f"적립: {test_points}P")
|
||||||
|
print(f"QR URL: {test_qr_url[:60]}...")
|
||||||
|
print("=" * 80)
|
||||||
|
|
||||||
|
# 미리보기 테스트
|
||||||
|
print("\n미리보기 모드 테스트...")
|
||||||
|
success, image_path = print_qr_label(
|
||||||
|
test_qr_url, test_tx_id, test_amount, test_points, test_time,
|
||||||
|
preview_mode=True
|
||||||
|
)
|
||||||
|
|
||||||
|
if success:
|
||||||
|
print(f"[OK] 테스트 성공!")
|
||||||
|
print(f"이미지 저장: {image_path}")
|
||||||
|
print(f"\n다음 명령으로 확인:")
|
||||||
|
print(f" start {image_path}")
|
||||||
|
else:
|
||||||
|
print("[ERROR] 테스트 실패")
|
||||||
191
backend/utils/qr_token_generator.py
Normal file
191
backend/utils/qr_token_generator.py
Normal file
@ -0,0 +1,191 @@
|
|||||||
|
"""
|
||||||
|
QR Claim Token 생성 모듈
|
||||||
|
후향적 적립을 위한 1회성 토큰 생성
|
||||||
|
"""
|
||||||
|
|
||||||
|
import hashlib
|
||||||
|
import secrets
|
||||||
|
from datetime import datetime, timedelta
|
||||||
|
import sys
|
||||||
|
import os
|
||||||
|
|
||||||
|
# DB 연결
|
||||||
|
sys.path.insert(0, os.path.join(os.path.dirname(__file__), '..'))
|
||||||
|
from db.dbsetup import DatabaseManager
|
||||||
|
|
||||||
|
# 설정값
|
||||||
|
MILEAGE_RATE = 0.03 # 3% 적립
|
||||||
|
TOKEN_EXPIRY_DAYS = 30 # 30일 유효기간
|
||||||
|
QR_BASE_URL = "https://mile.0bin.in/claim"
|
||||||
|
|
||||||
|
|
||||||
|
def generate_claim_token(transaction_id, total_amount, pharmacy_id="YANGGU001"):
|
||||||
|
"""
|
||||||
|
Claim Token 생성 (SHA256 해시 기반)
|
||||||
|
|
||||||
|
Args:
|
||||||
|
transaction_id (str): POS 거래 ID (SALE_MAIN.SL_NO_order)
|
||||||
|
total_amount (float): 총 판매 금액
|
||||||
|
pharmacy_id (str): 약국 코드
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
dict: {
|
||||||
|
'qr_url': QR 코드 URL,
|
||||||
|
'token_raw': 토큰 원문 (QR에만 포함),
|
||||||
|
'token_hash': SHA256 해시 (DB 저장용),
|
||||||
|
'claimable_points': 적립 가능 포인트,
|
||||||
|
'expires_at': 만료일시
|
||||||
|
}
|
||||||
|
|
||||||
|
보안 정책:
|
||||||
|
- token_raw는 DB에 저장하지 않음 (QR 코드에만 포함)
|
||||||
|
- token_hash만 DB 저장 (SHA256, 64자)
|
||||||
|
- nonce 사용으로 동일 거래도 매번 다른 토큰
|
||||||
|
|
||||||
|
URL 최적화:
|
||||||
|
- nonce: 64자 -> 12자로 단축 (6바이트 = 충분한 보안)
|
||||||
|
- 타임스탬프: 제거 (DB에만 저장)
|
||||||
|
- 결과: 약 80자 -> 빠른 QR 인식
|
||||||
|
"""
|
||||||
|
# 1. 랜덤 nonce 생성 (6바이트 = 12자 hex) - 대폭 단축!
|
||||||
|
nonce = secrets.token_hex(6)
|
||||||
|
|
||||||
|
# 2. 타임스탬프 (DB 저장용)
|
||||||
|
timestamp = datetime.now().isoformat()
|
||||||
|
|
||||||
|
# 3. 토큰 원문 생성 (검증용 - DB 저장 X)
|
||||||
|
token_raw = f"{transaction_id}:{nonce}:{timestamp}"
|
||||||
|
|
||||||
|
# 4. SHA256 해시 생성 (DB 저장용)
|
||||||
|
token_hash = hashlib.sha256(token_raw.encode('utf-8')).hexdigest()
|
||||||
|
|
||||||
|
# 5. QR URL 생성 (짧게!) - 타임스탬프 제외
|
||||||
|
qr_token_short = f"{transaction_id}:{nonce}"
|
||||||
|
qr_url = f"{QR_BASE_URL}?t={qr_token_short}"
|
||||||
|
|
||||||
|
# 6. 적립 포인트 계산
|
||||||
|
claimable_points = calculate_claimable_points(total_amount)
|
||||||
|
|
||||||
|
# 7. 만료일 계산 (30일 후)
|
||||||
|
expires_at = datetime.now() + timedelta(days=TOKEN_EXPIRY_DAYS)
|
||||||
|
|
||||||
|
return {
|
||||||
|
'qr_url': qr_url,
|
||||||
|
'token_raw': token_raw, # 전체 토큰 (해시 생성용)
|
||||||
|
'token_hash': token_hash,
|
||||||
|
'claimable_points': claimable_points,
|
||||||
|
'expires_at': expires_at,
|
||||||
|
'pharmacy_id': pharmacy_id,
|
||||||
|
'transaction_id': transaction_id,
|
||||||
|
'total_amount': total_amount
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def calculate_claimable_points(total_amount):
|
||||||
|
"""
|
||||||
|
적립 포인트 계산 (3% 정책)
|
||||||
|
|
||||||
|
Args:
|
||||||
|
total_amount (float): 판매 금액
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
int: 적립 포인트 (정수, 소수점 절사)
|
||||||
|
"""
|
||||||
|
return int(total_amount * MILEAGE_RATE)
|
||||||
|
|
||||||
|
|
||||||
|
def save_token_to_db(transaction_id, token_hash, total_amount, claimable_points,
|
||||||
|
expires_at, pharmacy_id):
|
||||||
|
"""
|
||||||
|
생성된 토큰을 SQLite DB에 저장
|
||||||
|
|
||||||
|
Args:
|
||||||
|
transaction_id (str): 거래 ID
|
||||||
|
token_hash (str): SHA256 해시
|
||||||
|
total_amount (float): 판매 금액
|
||||||
|
claimable_points (int): 적립 포인트
|
||||||
|
expires_at (datetime): 만료일시
|
||||||
|
pharmacy_id (str): 약국 코드
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
tuple: (성공 여부, 에러 메시지 or None)
|
||||||
|
|
||||||
|
중복 방지:
|
||||||
|
- transaction_id가 이미 존재하면 실패
|
||||||
|
- token_hash가 이미 존재하면 실패 (UNIQUE 제약)
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
db_manager = DatabaseManager()
|
||||||
|
conn = db_manager.get_sqlite_connection()
|
||||||
|
cursor = conn.cursor()
|
||||||
|
|
||||||
|
# 중복 체크 (transaction_id)
|
||||||
|
cursor.execute("""
|
||||||
|
SELECT id FROM claim_tokens WHERE transaction_id = ?
|
||||||
|
""", (transaction_id,))
|
||||||
|
|
||||||
|
if cursor.fetchone():
|
||||||
|
return (False, f"이미 QR이 생성된 거래입니다: {transaction_id}")
|
||||||
|
|
||||||
|
# INSERT
|
||||||
|
cursor.execute("""
|
||||||
|
INSERT INTO claim_tokens (
|
||||||
|
transaction_id, pharmacy_id, token_hash,
|
||||||
|
total_amount, claimable_points, expires_at
|
||||||
|
) VALUES (?, ?, ?, ?, ?, ?)
|
||||||
|
""", (
|
||||||
|
transaction_id,
|
||||||
|
pharmacy_id,
|
||||||
|
token_hash,
|
||||||
|
int(total_amount), # INTEGER 타입
|
||||||
|
claimable_points,
|
||||||
|
expires_at.strftime('%Y-%m-%d %H:%M:%S')
|
||||||
|
))
|
||||||
|
|
||||||
|
conn.commit()
|
||||||
|
|
||||||
|
return (True, None)
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
return (False, f"DB 저장 실패: {str(e)}")
|
||||||
|
|
||||||
|
|
||||||
|
# 테스트 코드
|
||||||
|
if __name__ == "__main__":
|
||||||
|
# 테스트
|
||||||
|
test_tx_id = "20251024000042"
|
||||||
|
test_amount = 50000.0
|
||||||
|
|
||||||
|
print("=" * 80)
|
||||||
|
print("Claim Token 생성 테스트")
|
||||||
|
print("=" * 80)
|
||||||
|
|
||||||
|
token_info = generate_claim_token(test_tx_id, test_amount)
|
||||||
|
|
||||||
|
print(f"거래 ID: {test_tx_id}")
|
||||||
|
print(f"판매 금액: {test_amount:,}원")
|
||||||
|
print(f"적립 포인트: {token_info['claimable_points']}P")
|
||||||
|
print(f"토큰 원문: {token_info['token_raw'][:80]}...")
|
||||||
|
print(f"토큰 해시: {token_info['token_hash']}")
|
||||||
|
print(f"QR URL: {token_info['qr_url'][:80]}...")
|
||||||
|
print(f"만료일: {token_info['expires_at']}")
|
||||||
|
print("=" * 80)
|
||||||
|
|
||||||
|
# DB 저장 테스트
|
||||||
|
print("\nDB 저장 테스트...")
|
||||||
|
success, error = save_token_to_db(
|
||||||
|
test_tx_id,
|
||||||
|
token_info['token_hash'],
|
||||||
|
test_amount,
|
||||||
|
token_info['claimable_points'],
|
||||||
|
token_info['expires_at'],
|
||||||
|
token_info['pharmacy_id']
|
||||||
|
)
|
||||||
|
|
||||||
|
if success:
|
||||||
|
print("[OK] DB 저장 성공")
|
||||||
|
print(f"\nSQLite DB 경로: backend/db/mileage.db")
|
||||||
|
print("다음 명령으로 확인:")
|
||||||
|
print(" sqlite3 backend/db/mileage.db \"SELECT * FROM claim_tokens\"")
|
||||||
|
else:
|
||||||
|
print(f"[ERROR] {error}")
|
||||||
309
docs/gitea사용방법.md
Normal file
309
docs/gitea사용방법.md
Normal file
@ -0,0 +1,309 @@
|
|||||||
|
# Gitea 리포지토리 생성 및 푸시 가이드
|
||||||
|
|
||||||
|
## 🏠 서버 정보
|
||||||
|
|
||||||
|
- **Gitea 서버**: `git.0bin.in`
|
||||||
|
- **사용자명**: `thug0bin`
|
||||||
|
- **이메일**: `thug0bin@gmail.com`
|
||||||
|
- **액세스 토큰**: `d83f70b219c6028199a498fb94009f4c1debc9a9`
|
||||||
|
|
||||||
|
## 🚀 새 리포지토리 생성 및 푸시 과정
|
||||||
|
|
||||||
|
### 1. 로컬 Git 리포지토리 초기화
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 프로젝트 디렉토리로 이동
|
||||||
|
cd /path/to/your/project
|
||||||
|
|
||||||
|
# Git 초기화
|
||||||
|
git init
|
||||||
|
|
||||||
|
# .gitignore 파일 생성 (필요시)
|
||||||
|
cat > .gitignore << 'EOF'
|
||||||
|
# Dependencies
|
||||||
|
node_modules/
|
||||||
|
npm-debug.log*
|
||||||
|
yarn-debug.log*
|
||||||
|
yarn-error.log*
|
||||||
|
|
||||||
|
# Build outputs
|
||||||
|
dist/
|
||||||
|
build/
|
||||||
|
|
||||||
|
# Environment variables
|
||||||
|
.env
|
||||||
|
.env.local
|
||||||
|
.env.development.local
|
||||||
|
.env.test.local
|
||||||
|
.env.production.local
|
||||||
|
|
||||||
|
# IDE
|
||||||
|
.vscode/
|
||||||
|
.idea/
|
||||||
|
|
||||||
|
# OS
|
||||||
|
.DS_Store
|
||||||
|
Thumbs.db
|
||||||
|
|
||||||
|
# Logs
|
||||||
|
*.log
|
||||||
|
|
||||||
|
# Runtime data
|
||||||
|
pids
|
||||||
|
*.pid
|
||||||
|
*.seed
|
||||||
|
*.pid.lock
|
||||||
|
|
||||||
|
# Python
|
||||||
|
__pycache__/
|
||||||
|
*.py[cod]
|
||||||
|
*$py.class
|
||||||
|
*.so
|
||||||
|
.Python
|
||||||
|
env/
|
||||||
|
venv/
|
||||||
|
|
||||||
|
# Database
|
||||||
|
*.db
|
||||||
|
*.sqlite
|
||||||
|
*.sqlite3
|
||||||
|
EOF
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2. Git 사용자 설정 확인
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Git 사용자 정보 확인
|
||||||
|
git config --list | grep -E "user"
|
||||||
|
|
||||||
|
# 설정되지 않은 경우 설정
|
||||||
|
git config --global user.name "시골약사"
|
||||||
|
git config --global user.email "thug0bin@gmail.com"
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3. 첫 번째 커밋
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 모든 파일 스테이징
|
||||||
|
git add .
|
||||||
|
|
||||||
|
# 첫 커밋 (상세한 커밋 메시지 예시)
|
||||||
|
git commit -m "$(cat <<'EOF'
|
||||||
|
Initial commit: [프로젝트명]
|
||||||
|
|
||||||
|
✨ [주요 기능 설명]
|
||||||
|
- 기능 1
|
||||||
|
- 기능 2
|
||||||
|
- 기능 3
|
||||||
|
|
||||||
|
🛠️ 기술 스택:
|
||||||
|
- 사용된 기술들 나열
|
||||||
|
|
||||||
|
🔧 주요 구성:
|
||||||
|
- 프로젝트 구조 설명
|
||||||
|
|
||||||
|
🤖 Generated with [Claude Code](https://claude.ai/code)
|
||||||
|
|
||||||
|
Co-Authored-By: Claude <noreply@anthropic.com>
|
||||||
|
EOF
|
||||||
|
)"
|
||||||
|
```
|
||||||
|
|
||||||
|
### 4. 원격 리포지토리 연결 및 푸시
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 원격 리포지토리 추가 (리포지토리명을 실제 이름으로 변경)
|
||||||
|
git remote add origin https://thug0bin:d83f70b219c6028199a498fb94009f4c1debc9a9@git.0bin.in/thug0bin/[REPOSITORY_NAME].git
|
||||||
|
|
||||||
|
# 브랜치를 main으로 변경
|
||||||
|
git branch -M main
|
||||||
|
|
||||||
|
# 원격 리포지토리로 푸시
|
||||||
|
git push -u origin main
|
||||||
|
```
|
||||||
|
|
||||||
|
## 📝 리포지토리명 네이밍 규칙
|
||||||
|
|
||||||
|
### 권장 네이밍 패턴:
|
||||||
|
- **프론트엔드 프로젝트**: `project-name-frontend`
|
||||||
|
- **백엔드 프로젝트**: `project-name-backend`
|
||||||
|
- **풀스택 프로젝트**: `project-name-fullstack`
|
||||||
|
- **도구/유틸리티**: `tool-name-utils`
|
||||||
|
- **문서/가이드**: `project-name-docs`
|
||||||
|
|
||||||
|
### 예시:
|
||||||
|
- `figma-admin-dashboard` ✅
|
||||||
|
- `anipharm-api-server` ✅
|
||||||
|
- `inventory-management-system` ✅
|
||||||
|
- `member-portal-frontend` ✅
|
||||||
|
|
||||||
|
## 🔄 기존 리포지토리에 추가 커밋
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 변경사항 확인
|
||||||
|
git status
|
||||||
|
|
||||||
|
# 변경된 파일 스테이징
|
||||||
|
git add .
|
||||||
|
|
||||||
|
# 또는 특정 파일만 스테이징
|
||||||
|
git add path/to/specific/file
|
||||||
|
|
||||||
|
# 커밋
|
||||||
|
git commit -m "커밋 메시지"
|
||||||
|
|
||||||
|
# 푸시
|
||||||
|
git push origin main
|
||||||
|
```
|
||||||
|
|
||||||
|
## 🌿 브랜치 작업
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 새 브랜치 생성 및 전환
|
||||||
|
git checkout -b feature/new-feature
|
||||||
|
|
||||||
|
# 브랜치에서 작업 후 커밋
|
||||||
|
git add .
|
||||||
|
git commit -m "Feature: 새로운 기능 추가"
|
||||||
|
|
||||||
|
# 브랜치 푸시
|
||||||
|
git push -u origin feature/new-feature
|
||||||
|
|
||||||
|
# main 브랜치로 돌아가기
|
||||||
|
git checkout main
|
||||||
|
|
||||||
|
# 브랜치 병합 (필요시)
|
||||||
|
git merge feature/new-feature
|
||||||
|
```
|
||||||
|
|
||||||
|
## 🛠️ 자주 사용하는 Git 명령어
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 현재 상태 확인
|
||||||
|
git status
|
||||||
|
|
||||||
|
# 변경 내역 확인
|
||||||
|
git diff
|
||||||
|
|
||||||
|
# 커밋 히스토리 확인
|
||||||
|
git log --oneline
|
||||||
|
|
||||||
|
# 원격 리포지토리 정보 확인
|
||||||
|
git remote -v
|
||||||
|
|
||||||
|
# 특정 포트 프로세스 종료 (개발 서버 관련)
|
||||||
|
lsof -ti:PORT_NUMBER | xargs -r kill -9
|
||||||
|
```
|
||||||
|
|
||||||
|
## 🔧 포트 관리 스크립트
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 특정 포트 종료 함수 추가 (bashrc에 추가 가능)
|
||||||
|
killport() {
|
||||||
|
if [ -z "$1" ]; then
|
||||||
|
echo "Usage: killport <port_number>"
|
||||||
|
return 1
|
||||||
|
fi
|
||||||
|
lsof -ti:$1 | xargs -r kill -9
|
||||||
|
echo "Killed processes on port $1"
|
||||||
|
}
|
||||||
|
|
||||||
|
# 사용 예시
|
||||||
|
# killport 7738
|
||||||
|
# killport 5000
|
||||||
|
```
|
||||||
|
|
||||||
|
## 📋 VS Code 워크스페이스 설정
|
||||||
|
|
||||||
|
여러 리포지토리를 동시에 관리하려면 워크스페이스 파일을 생성하세요:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"folders": [
|
||||||
|
{
|
||||||
|
"name": "Main Repository",
|
||||||
|
"path": "."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "New Project",
|
||||||
|
"path": "./new-project-folder"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"settings": {
|
||||||
|
"git.enableSmartCommit": true,
|
||||||
|
"git.confirmSync": false,
|
||||||
|
"git.autofetch": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## 🚨 문제 해결
|
||||||
|
|
||||||
|
### 1. 인증 실패
|
||||||
|
```bash
|
||||||
|
# 토큰이 만료된 경우, 새 토큰으로 원격 URL 업데이트
|
||||||
|
git remote set-url origin https://thug0bin:NEW_TOKEN@git.0bin.in/thug0bin/repo-name.git
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2. 푸시 거부
|
||||||
|
```bash
|
||||||
|
# 원격 변경사항을 먼저 가져오기
|
||||||
|
git pull origin main --rebase
|
||||||
|
|
||||||
|
# 충돌 해결 후 푸시
|
||||||
|
git push origin main
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3. 대용량 파일 문제
|
||||||
|
```bash
|
||||||
|
# Git LFS 설정 (필요시)
|
||||||
|
git lfs install
|
||||||
|
git lfs track "*.zip"
|
||||||
|
git lfs track "*.gz"
|
||||||
|
git add .gitattributes
|
||||||
|
```
|
||||||
|
|
||||||
|
## 📊 커밋 메시지 템플릿
|
||||||
|
|
||||||
|
### 기본 템플릿:
|
||||||
|
```
|
||||||
|
타입: 간단한 설명
|
||||||
|
|
||||||
|
상세한 설명 (선택사항)
|
||||||
|
|
||||||
|
🤖 Generated with [Claude Code](https://claude.ai/code)
|
||||||
|
|
||||||
|
Co-Authored-By: Claude <noreply@anthropic.com>
|
||||||
|
```
|
||||||
|
|
||||||
|
### 타입별 예시:
|
||||||
|
- `✨ feat: 새로운 기능 추가`
|
||||||
|
- `🐛 fix: 버그 수정`
|
||||||
|
- `📝 docs: 문서 업데이트`
|
||||||
|
- `🎨 style: 코드 포맷팅`
|
||||||
|
- `♻️ refactor: 코드 리팩토링`
|
||||||
|
- `⚡ perf: 성능 개선`
|
||||||
|
- `✅ test: 테스트 추가`
|
||||||
|
- `🔧 chore: 빌드 설정 변경`
|
||||||
|
|
||||||
|
## 🔗 유용한 링크
|
||||||
|
|
||||||
|
- **Gitea 웹 인터페이스**: https://git.0bin.in/
|
||||||
|
- **내 리포지토리 목록**: https://git.0bin.in/thug0bin
|
||||||
|
- **새 리포지토리 생성**: https://git.0bin.in/repo/create
|
||||||
|
|
||||||
|
## 💡 팁과 모범 사례
|
||||||
|
|
||||||
|
1. **정기적인 커밋**: 작은 단위로 자주 커밋하세요
|
||||||
|
2. **의미있는 커밋 메시지**: 변경 사항을 명확히 설명하세요
|
||||||
|
3. **브랜치 활용**: 기능별로 브랜치를 나누어 작업하세요
|
||||||
|
4. **.gitignore 활용**: 불필요한 파일은 제외하세요
|
||||||
|
5. **문서화**: README.md와 같은 문서를 항상 업데이트하세요
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
**작성일**: 2025년 7월 29일
|
||||||
|
**마지막 업데이트**: 토큰 및 서버 정보 최신화
|
||||||
|
**참고**: 이 가이드는 재사용 가능하도록 작성되었습니다. 새 프로젝트마다 참고하세요.
|
||||||
|
|
||||||
|
> 💡 **중요**: 액세스 토큰은 보안이 중요한 정보입니다. 공개 저장소에 업로드하지 마세요!
|
||||||
Loading…
Reference in New Issue
Block a user