Compare commits

..

9 Commits

Author SHA1 Message Date
8190601340 docs: Gitea 사용 방법 가이드 추가 2026-01-23 16:37:11 +09:00
952ad773f1 chore: .gitignore에 .claude/ 디렉토리 추가 2026-01-23 16:37:05 +09:00
a6c14a6b75 docs: 프로젝트 전체 문서 작성 (CLAUDECODE.md)
- 프로젝트 개요 및 접속 URL
- 핵심 기능 (Phase 2, 3)
  * QR 라벨 인쇄
  * 간편 적립 웹앱
  * 관리자 페이지
  * POS GUI SQLite 연동
  * 실시간 동기화 (30초 자동 새로고침)
- 데이터베이스 스키마 (SQLite + MSSQL)
- API 엔드포인트 명세
- 보안 정책 및 설정
- 테스트 방법 및 트러블슈팅

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2026-01-23 16:36:51 +09:00
b4de6ff791 feat: 통합 테스트 및 샘플 코드 추가
- test_integration.py: QR 토큰 생성 및 라벨 테스트
- samples/barcode_print.py: Brother QL 프린터 예제
- samples/barcode_reader_gui.py: 바코드 리더 GUI 참고 코드

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2026-01-23 16:36:41 +09:00
4581ebb7c5 feat: POS GUI SQLite 연동 및 실시간 동기화 기능 추가
- SQLite 적립 사용자 정보 표시 (이름, 전화번호)
  * MSSQL 판매 내역과 SQLite 마일리지 데이터 LEFT JOIN
  * 적립 사용자 녹색 볼드 텍스트로 강조
  * 6번째 컬럼 '적립 사용자' 추가
- QR 생성 기능 활성화
  * QRGeneratorThread로 백그라운드 처리
  * 미리보기 모드 체크박스 추가
  * QRLabelPreviewDialog 팝업 구현
- 자동 새로고침 (30초 주기)
  * QTimer로 주기적으로 refresh_sales() 호출
  * 실시간 적립 상태 반영
- 윈도우 크기 1100px로 확대

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2026-01-23 16:36:33 +09:00
3889e2354f feat: Flask 웹 서버 및 마일리지 적립 기능 구현
- 간편 적립: 전화번호 + 이름만으로 QR 적립
- 자동 회원 가입: 신규 사용자 자동 등록
- 마이페이지: 포인트 조회 및 적립 내역 확인
- 관리자 페이지: 전체 사용자/적립 현황 대시보드
- 거래 세부 조회 API: MSSQL 연동으로 판매 상품 상세 확인
- 모던 UI: Noto Sans KR 폰트, 반응형 디자인
- 포트: 7001 (리버스 프록시: https://mile.0bin.in)

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2026-01-23 16:36:14 +09:00
fdc369c139 feat: MSSQL 테이블 스키마 확인 유틸리티 추가
- INFORMATION_SCHEMA 조회로 컬럼 정보 확인
- SALE_MAIN, SALE_SUB 테이블 구조 검증
- 컬럼명, 데이터타입, NULL 여부 출력
2026-01-23 16:36:03 +09:00
7aad05acb9 feat: QR 토큰 생성 및 라벨 인쇄 모듈 추가
- qr_token_generator.py: SHA256 기반 1회성 토큰 생성
  * 3% 마일리지 적립 정책
  * 30일 유효기간
  * nonce 기반 중복 방지
  * QR_BASE_URL: https://mile.0bin.in/claim
- qr_label_printer.py: Brother QL-810W 라벨 인쇄
  * 800x306px 라벨 이미지 생성
  * QR 코드 + 거래 정보 포함
  * 미리보기 모드 및 프린터 전송 지원
2026-01-23 16:35:56 +09:00
c2dc42c565 feat: SQLite 연결 기능 추가
- get_sqlite_connection() 메서드 추가
- mileage.db 자동 생성 및 스키마 초기화
- Row Factory 설정으로 dict 형태 결과 반환
- check_same_thread=False로 멀티스레드 지원
- close_all()에 SQLite 연결 종료 로직 추가
2026-01-23 16:35:47 +09:00
22 changed files with 7495 additions and 27 deletions

1
.gitignore vendored
View File

@ -85,3 +85,4 @@ docker-compose.override.yml
# Temp files
tmp/
*.tmp
.claude/

441
CLAUDECODE.md Normal file
View 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
View 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)

View 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')

View File

@ -11,6 +11,8 @@ from sqlalchemy import Column, String, Integer, DateTime, Text
import urllib.parse
import platform
import pyodbc
import sqlite3
from pathlib import Path
# 기본 설정
Base = declarative_base()
@ -130,6 +132,10 @@ class DatabaseManager:
self.sessions = {}
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'):
"""특정 데이터베이스 엔진 반환"""
if database not in self.engines:
@ -179,6 +185,56 @@ class DatabaseManager:
# 새 세션 생성
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'):
"""연결 테스트"""
try:
@ -196,6 +252,11 @@ class DatabaseManager:
for engine in self.engines.values():
engine.dispose()
# SQLite 연결 종료
if self.sqlite_conn:
self.sqlite_conn.close()
self.sqlite_conn = None
# 전역 데이터베이스 매니저 인스턴스
db_manager = DatabaseManager()

View File

@ -9,15 +9,19 @@ from datetime import datetime
from PyQt5.QtWidgets import (
QApplication, QMainWindow, QWidget, QVBoxLayout, QHBoxLayout,
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
# 데이터베이스 연결 (backend/ 폴더를 Python 경로에 추가)
sys.path.insert(0, os.path.join(os.path.dirname(__file__), '..'))
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):
"""
@ -36,12 +40,19 @@ class SalesQueryThread(QThread):
self.date_str = date_str
def run(self):
"""스레드 실행 (SALE_MAIN 조회)"""
conn = None
"""스레드 실행 (SALE_MAIN 조회 + SQLite 적립 사용자 조회)"""
mssql_conn = None
sqlite_conn = None
try:
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에서 오늘 판매 내역 조회
query = """
@ -55,27 +66,43 @@ class SalesQueryThread(QThread):
ORDER BY M.InsertTime DESC
"""
cursor.execute(query, self.date_str)
rows = cursor.fetchall()
mssql_cursor.execute(query, self.date_str)
rows = mssql_cursor.fetchall()
sales_list = []
for row in rows:
order_no, insert_time, sale_amount, customer = row
# 품목 수 조회 (SALE_SUB)
cursor.execute("""
mssql_cursor.execute("""
SELECT COUNT(*) FROM SALE_SUB
WHERE SL_NO_order = ?
""", 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
# 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({
'order_no': order_no,
'time': insert_time.strftime('%H:%M') if insert_time else '--:--',
'amount': float(sale_amount) if sale_amount else 0.0,
'customer': customer,
'item_count': item_count
'item_count': item_count,
'claimed_user': claimed_info
})
self.query_complete.emit(sales_list)
@ -83,8 +110,150 @@ class SalesQueryThread(QThread):
except Exception as e:
self.query_error.emit(str(e))
finally:
if conn:
conn.close()
if mssql_conn:
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):
@ -197,13 +366,14 @@ class POSSalesGUI(QMainWindow):
super().__init__()
self.db_manager = DatabaseManager()
self.sales_thread = None
self.qr_thread = None # QR 생성 스레드 추가
self.sales_data = []
self.init_ui()
def init_ui(self):
"""UI 초기화"""
self.setWindowTitle('POS 판매 조회')
self.setGeometry(100, 100, 900, 600)
self.setGeometry(100, 100, 1100, 600)
# 중앙 위젯
central_widget = QWidget()
@ -232,13 +402,21 @@ class POSSalesGUI(QMainWindow):
self.refresh_btn.clicked.connect(self.refresh_sales)
settings_layout.addWidget(self.refresh_btn)
# QR 생성 버튼 (Phase 2 준비 - 현재 비활성화)
# QR 생성 버튼 (활성화)
self.qr_btn = QPushButton('QR 생성')
self.qr_btn.setEnabled(False)
self.qr_btn.setStyleSheet('background-color: #9E9E9E; color: white; padding: 8px; font-weight: bold;')
self.qr_btn.setToolTip('후향적 적립 QR (추후 개발)')
self.qr_btn.setEnabled(True) # 활성화!
self.qr_btn.setStyleSheet('background-color: #FF9800; color: white; padding: 8px; font-weight: bold;')
self.qr_btn.setToolTip('선택된 거래의 QR 적립 라벨 생성')
self.qr_btn.clicked.connect(self.generate_qr_label) # 이벤트 연결
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()
main_layout.addWidget(settings_group)
@ -249,15 +427,16 @@ class POSSalesGUI(QMainWindow):
sales_group.setLayout(sales_layout)
self.sales_table = QTableWidget()
self.sales_table.setColumnCount(5)
self.sales_table.setColumnCount(6)
self.sales_table.setHorizontalHeaderLabels([
'주문번호', '시간', '금액', '고객명', '품목수'
'주문번호', '시간', '금액', '고객명', '품목수', '적립 사용자'
])
self.sales_table.setColumnWidth(0, 180)
self.sales_table.setColumnWidth(1, 80)
self.sales_table.setColumnWidth(2, 120)
self.sales_table.setColumnWidth(3, 120)
self.sales_table.setColumnWidth(4, 80)
self.sales_table.setColumnWidth(0, 160)
self.sales_table.setColumnWidth(1, 70)
self.sales_table.setColumnWidth(2, 110)
self.sales_table.setColumnWidth(3, 100)
self.sales_table.setColumnWidth(4, 70)
self.sales_table.setColumnWidth(5, 180)
self.sales_table.setSelectionBehavior(QTableWidget.SelectRows)
self.sales_table.doubleClicked.connect(self.show_sale_detail)
@ -283,6 +462,11 @@ class POSSalesGUI(QMainWindow):
# 초기 조회 실행
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):
"""날짜 변경 시 유효성 검사"""
today = QDate.currentDate()
@ -339,7 +523,7 @@ class POSSalesGUI(QMainWindow):
amount_item.setTextAlignment(Qt.AlignRight | Qt.AlignVCenter)
self.sales_table.setItem(row, 2, amount_item)
# 고객명
# 고객명 (MSSQL)
self.sales_table.setItem(row, 3, QTableWidgetItem(sale['customer']))
# 품목수 (중앙 정렬)
@ -347,6 +531,16 @@ class POSSalesGUI(QMainWindow):
count_item.setTextAlignment(Qt.AlignCenter)
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):
"""DB 조회 에러 처리"""
QMessageBox.critical(self, '오류', f'조회 실패:\n{error_msg}')
@ -364,10 +558,89 @@ class POSSalesGUI(QMainWindow):
detail_dialog = SaleDetailDialog(order_no, self)
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):
"""종료 시 정리"""
# 자동 새로고침 타이머 중지
if hasattr(self, 'refresh_timer'):
self.refresh_timer.stop()
if self.sales_thread and self.sales_thread.isRunning():
self.sales_thread.wait()
if self.qr_thread and self.qr_thread.isRunning(): # QR 스레드 추가
self.qr_thread.wait()
self.db_manager.close_all()
event.accept()

View 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 '실패'}")

View 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)

View 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)를 전송합니다.

View 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()

View 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()

View 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": "투약대"
}
]
}

File diff suppressed because it is too large Load Diff

View 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>

View 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>

View 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>

View 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>

View 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>

View 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)

View 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] 테스트 실패")

View 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
View 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일
**마지막 업데이트**: 토큰 및 서버 정보 최신화
**참고**: 이 가이드는 재사용 가능하도록 작성되었습니다. 새 프로젝트마다 참고하세요.
> 💡 **중요**: 액세스 토큰은 보안이 중요한 정보입니다. 공개 저장소에 업로드하지 마세요!