+ 이 화면에서 바로 적립할 수 있습니다 +
diff --git a/backend/app.py b/backend/app.py index b349cb3..6a4c53a 100644 --- a/backend/app.py +++ b/backend/app.py @@ -45,6 +45,9 @@ db_manager = DatabaseManager() # KST 타임존 (UTC+9) KST = timezone(timedelta(hours=9)) +# 키오스크 현재 세션 (메모리 변수, 서버 재시작 시 초기화) +kiosk_current_session = None + def utc_to_kst_str(utc_time_str): """ @@ -1846,6 +1849,208 @@ def admin(): recent_tokens=recent_tokens) +# ============================================================================ +# 키오스크 적립 +# ============================================================================ + +@app.route('/kiosk') +def kiosk(): + """키오스크 메인 페이지 (전체 화면 웹 UI)""" + return render_template('kiosk.html') + + +@app.route('/api/kiosk/trigger', methods=['POST']) +def api_kiosk_trigger(): + """ + POS → 키오스크 세션 생성 + POST /api/kiosk/trigger + Body: {"transaction_id": "...", "amount": 50000} + """ + global kiosk_current_session + + data = request.get_json() + transaction_id = data.get('transaction_id') + amount = data.get('amount', 0) + + if not transaction_id: + return jsonify({'success': False, 'message': 'transaction_id가 필요합니다.'}), 400 + + try: + conn = db_manager.get_sqlite_connection() + cursor = conn.cursor() + + # QR 토큰 존재 확인 + cursor.execute("SELECT id, token_hash, claimable_points, claimed_at FROM claim_tokens WHERE transaction_id = ?", + (transaction_id,)) + token_row = cursor.fetchone() + + if token_row and token_row['claimed_at']: + return jsonify({'success': False, 'message': '이미 적립된 거래입니다.'}), 400 + + if token_row: + # 기존 토큰 사용 + claimable_points = token_row['claimable_points'] + # nonce 재생성 (기존 토큰의 hash에서 nonce를 알 수 없으므로 QR URL 재생성) + from utils.qr_token_generator import QR_BASE_URL + # 기존 토큰의 nonce를 DB에서 가져올 수 없으므로, 새 nonce로 QR 재생성은 불가 + # → 키오스크에서는 QR 대신 transaction_id + nonce를 직접 제공 + qr_url = None + else: + # 새 토큰 생성 + from utils.qr_token_generator import generate_claim_token, save_token_to_db + token_info = generate_claim_token(transaction_id, float(amount)) + success, error = save_token_to_db( + transaction_id, + token_info['token_hash'], + float(amount), + token_info['claimable_points'], + token_info['expires_at'], + token_info['pharmacy_id'] + ) + if not success: + return jsonify({'success': False, 'message': error}), 500 + + claimable_points = token_info['claimable_points'] + qr_url = token_info['qr_url'] + + # 키오스크 세션 저장 + kiosk_current_session = { + 'transaction_id': transaction_id, + 'amount': int(amount), + 'points': claimable_points, + 'qr_url': qr_url, + 'created_at': datetime.now(KST).isoformat() + } + + return jsonify({ + 'success': True, + 'message': f'키오스크 적립 대기 ({claimable_points}P)', + 'points': claimable_points + }) + + except Exception as e: + logging.error(f"키오스크 트리거 오류: {e}") + return jsonify({'success': False, 'message': f'오류: {str(e)}'}), 500 + + +@app.route('/api/kiosk/current') +def api_kiosk_current(): + """ + 키오스크 폴링 - 현재 세션 조회 + GET /api/kiosk/current + """ + global kiosk_current_session + + if kiosk_current_session is None: + return jsonify({'active': False}) + + # 5분 경과 시 자동 만료 + created = datetime.fromisoformat(kiosk_current_session['created_at']) + if datetime.now(KST) - created > timedelta(minutes=5): + kiosk_current_session = None + return jsonify({'active': False}) + + return jsonify({ + 'active': True, + 'transaction_id': kiosk_current_session['transaction_id'], + 'amount': kiosk_current_session['amount'], + 'points': kiosk_current_session['points'], + 'qr_url': kiosk_current_session.get('qr_url') + }) + + +@app.route('/api/kiosk/claim', methods=['POST']) +def api_kiosk_claim(): + """ + 키오스크 전화번호 적립 + POST /api/kiosk/claim + Body: {"phone": "01012345678"} + """ + global kiosk_current_session + + if kiosk_current_session is None: + return jsonify({'success': False, 'message': '적립 대기 중인 거래가 없습니다.'}), 400 + + data = request.get_json() + phone = data.get('phone', '').strip().replace('-', '').replace(' ', '') + + if len(phone) < 10: + return jsonify({'success': False, 'message': '올바른 전화번호를 입력해주세요.'}), 400 + + transaction_id = kiosk_current_session['transaction_id'] + + try: + conn = db_manager.get_sqlite_connection() + cursor = conn.cursor() + + # claim_tokens에서 nonce 조회를 위해 token_hash로 검증 + cursor.execute(""" + SELECT id, transaction_id, token_hash, total_amount, claimable_points, + pharmacy_id, expires_at, claimed_at, claimed_by_user_id + FROM claim_tokens WHERE transaction_id = ? + """, (transaction_id,)) + token_row = cursor.fetchone() + + if not token_row: + return jsonify({'success': False, 'message': '토큰을 찾을 수 없습니다.'}), 400 + + if token_row['claimed_at']: + kiosk_current_session = None + return jsonify({'success': False, 'message': '이미 적립된 거래입니다.'}), 400 + + # 만료 확인 + expires_at = datetime.strptime(token_row['expires_at'], '%Y-%m-%d %H:%M:%S') + if datetime.now() > expires_at: + kiosk_current_session = None + return jsonify({'success': False, 'message': '만료된 거래입니다.'}), 400 + + # token_info 딕셔너리 구성 (claim_mileage 호환) + token_info = { + 'id': token_row['id'], + 'transaction_id': token_row['transaction_id'], + 'total_amount': token_row['total_amount'], + 'claimable_points': token_row['claimable_points'] + } + + # 사용자 조회/생성 + user_id, is_new = get_or_create_user(phone, '고객') + + # 마일리지 적립 + claim_success, claim_msg, new_balance = claim_mileage(user_id, token_info) + + if not claim_success: + return jsonify({'success': False, 'message': claim_msg}), 500 + + # 키오스크 세션 클리어 + claimed_points = token_info['claimable_points'] + kiosk_current_session = None + + # 알림톡 발송 (fire-and-forget) + try: + from services.nhn_alimtalk import send_mileage_claim_alimtalk + + # 유저 이름 조회 (기존 유저는 실명이 있을 수 있음) + cursor.execute("SELECT nickname FROM users WHERE id = ?", (user_id,)) + user_row = cursor.fetchone() + user_name = user_row['nickname'] if user_row else '고객' + + send_mileage_claim_alimtalk(phone, user_name, claimed_points, new_balance) + except Exception as alimtalk_err: + logging.warning(f"알림톡 발송 실패 (적립은 완료): {alimtalk_err}") + + return jsonify({ + 'success': True, + 'message': f'{claimed_points}P 적립 완료!', + 'points': claimed_points, + 'balance': new_balance, + 'is_new_user': is_new + }) + + except Exception as e: + logging.error(f"키오스크 적립 오류: {e}") + return jsonify({'success': False, 'message': f'오류: {str(e)}'}), 500 + + if __name__ == '__main__': # 개발 모드로 실행 app.run(host='0.0.0.0', port=7001, debug=True) diff --git a/backend/gui/pos_sales_gui.py b/backend/gui/pos_sales_gui.py index 2f5eb1a..161433b 100644 --- a/backend/gui/pos_sales_gui.py +++ b/backend/gui/pos_sales_gui.py @@ -7,6 +7,18 @@ import sys import os import json from datetime import datetime + +# Qt 플랫폼 플러그인 경로 자동 설정 (PyQt5 import 전에 반드시 설정) +if not os.environ.get('QT_QPA_PLATFORM_PLUGIN_PATH'): + import importlib.util + _spec = importlib.util.find_spec('PyQt5') + if _spec and _spec.origin: + _pyqt5_plugins = os.path.join( + os.path.dirname(_spec.origin), 'Qt5', 'plugins', 'platforms' + ) + if os.path.isdir(_pyqt5_plugins): + os.environ['QT_QPA_PLATFORM_PLUGIN_PATH'] = _pyqt5_plugins + from PyQt5.QtWidgets import ( QApplication, QMainWindow, QWidget, QVBoxLayout, QHBoxLayout, QPushButton, QLabel, QGroupBox, QTableWidget, QTableWidgetItem, @@ -624,6 +636,14 @@ class POSSalesGUI(QMainWindow): self.qr_btn.clicked.connect(self.generate_qr_label) # 이벤트 연결 settings_layout.addWidget(self.qr_btn) + # 키오스크 적립 버튼 + self.kiosk_btn = QPushButton('키오스크 적립') + self.kiosk_btn.setStyleSheet( + 'background-color: #6366f1; color: white; padding: 8px; font-weight: bold;') + self.kiosk_btn.setToolTip('선택된 거래를 키오스크 화면에 표시') + self.kiosk_btn.clicked.connect(self.trigger_kiosk_claim) + settings_layout.addWidget(self.kiosk_btn) + # 미리보기 모드 체크박스 추가 self.preview_checkbox = QCheckBox('미리보기 모드') self.preview_checkbox.setChecked(True) # 기본값: 미리보기 @@ -928,6 +948,35 @@ class POSSalesGUI(QMainWindow): except: return False + def trigger_kiosk_claim(self): + """선택된 판매 건을 키오스크에 표시""" + 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() + amount = float(amount_text.replace(',', '').replace('원', '')) + + try: + import requests as req + resp = req.post( + 'http://localhost:7001/api/kiosk/trigger', + json={'transaction_id': order_no, 'amount': amount}, + timeout=5 + ) + result = resp.json() + + if result.get('success'): + self.status_label.setText(f'키오스크 적립 대기 중 ({result.get("points", 0)}P)') + self.status_label.setStyleSheet( + 'color: #6366f1; font-size: 12px; padding: 5px; font-weight: bold;') + else: + QMessageBox.warning(self, '키오스크', result.get('message', '전송 실패')) + except Exception as e: + QMessageBox.critical(self, '오류', f'Flask 서버 연결 실패:\n{str(e)}') + def generate_qr_label(self): """선택된 판매 건에 대해 QR 라벨 생성""" # 선택된 행 확인 diff --git a/backend/services/nhn_alimtalk.py b/backend/services/nhn_alimtalk.py new file mode 100644 index 0000000..5f94dd2 --- /dev/null +++ b/backend/services/nhn_alimtalk.py @@ -0,0 +1,111 @@ +""" +NHN Cloud 알림톡 발송 서비스 +마일리지 적립 완료 등 알림톡 발송 +""" + +import os +import logging +from datetime import datetime, timezone, timedelta + +import requests + +logger = logging.getLogger(__name__) + +# NHN Cloud 알림톡 설정 +APPKEY = os.getenv('NHN_ALIMTALK_APPKEY', 'u0TLUaXXY9bfQFkY') +SECRET_KEY = os.getenv('NHN_ALIMTALK_SECRET', 'naraGEUJfpkRu1fgirKewJtwADqWQ5gY') +SENDER_KEY = os.getenv('NHN_ALIMTALK_SENDER', '341352077bce225195ccc2697fb449f723e70982') + +API_BASE = f'https://api-alimtalk.cloud.toast.com/alimtalk/v2.3/appkeys/{APPKEY}' + +# KST 타임존 +KST = timezone(timedelta(hours=9)) + + +def _send_alimtalk(template_code, recipient_no, template_params): + """ + 알림톡 발송 공통 함수 + + Args: + template_code: 템플릿 코드 + recipient_no: 수신 번호 (01012345678) + template_params: 템플릿 변수 딕셔너리 + + Returns: + tuple: (성공 여부, 메시지) + """ + url = f'{API_BASE}/messages' + headers = { + 'Content-Type': 'application/json;charset=UTF-8', + 'X-Secret-Key': SECRET_KEY + } + data = { + 'senderKey': SENDER_KEY, + 'templateCode': template_code, + 'recipientList': [ + { + 'recipientNo': recipient_no, + 'templateParameter': template_params + } + ] + } + + try: + resp = requests.post(url, headers=headers, json=data, timeout=10) + result = resp.json() + + if resp.status_code == 200 and result.get('header', {}).get('isSuccessful'): + logger.info(f"알림톡 발송 성공: {template_code} → {recipient_no}") + return (True, "발송 성공") + else: + error_msg = result.get('header', {}).get('resultMessage', str(result)) + logger.warning(f"알림톡 발송 실패: {template_code} → {recipient_no}: {error_msg}") + return (False, error_msg) + + except requests.exceptions.Timeout: + logger.warning(f"알림톡 발송 타임아웃: {template_code} → {recipient_no}") + return (False, "타임아웃") + except Exception as e: + logger.warning(f"알림톡 발송 오류: {template_code} → {recipient_no}: {e}") + return (False, str(e)) + + +def send_mileage_claim_alimtalk(phone, name, points, balance): + """ + 마일리지 적립 완료 알림톡 발송 + + Args: + phone: 수신 전화번호 (01012345678) + name: 고객명 + points: 적립 포인트 + balance: 적립 후 총 잔액 + + Returns: + tuple: (성공 여부, 메시지) + """ + now_kst = datetime.now(KST).strftime('%Y-%m-%d %H:%M') + + # MILEAGE_CLAIM_V2 (버튼 포함 버전) 우선 시도 + template_code = 'MILEAGE_CLAIM_V2' + params = { + '고객명': name, + '적립포인트': f'{points:,}', + '총잔액': f'{balance:,}', + '적립일시': now_kst, + '전화번호': phone + } + + success, msg = _send_alimtalk(template_code, phone, params) + + if not success: + # V2 실패 시 V1 (버튼 없는 버전) 시도 + template_code = 'MILEAGE_CLAIM' + params_v1 = { + '고객명': name, + '적립포인트': f'{points:,}', + '총잔액': f'{balance:,}', + '적립일시': now_kst + } + success, msg = _send_alimtalk(template_code, phone, params_v1) + + return (success, msg) diff --git a/backend/templates/kiosk.html b/backend/templates/kiosk.html new file mode 100644 index 0000000..b09c52a --- /dev/null +++ b/backend/templates/kiosk.html @@ -0,0 +1,638 @@ + + +
+ + +