diff --git a/backend/utils/qr_label_printer.py b/backend/utils/qr_label_printer.py new file mode 100644 index 0000000..9678fdc --- /dev/null +++ b/backend/utils/qr_label_printer.py @@ -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] 테스트 실패") diff --git a/backend/utils/qr_token_generator.py b/backend/utils/qr_token_generator.py new file mode 100644 index 0000000..12ee72b --- /dev/null +++ b/backend/utils/qr_token_generator.py @@ -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}")