From e37659dc047d0e03b7f954ccde9b241265a564ad Mon Sep 17 00:00:00 2001 From: thug0bin Date: Mon, 2 Mar 2026 15:26:51 +0900 Subject: [PATCH] =?UTF-8?q?feat:=20POS=20=EC=8B=A4=EC=8B=9C=EA=B0=84=20?= =?UTF-8?q?=ED=8C=90=EB=A7=A4=20=EC=A1=B0=ED=9A=8C=20=EC=9B=B9=20=ED=8E=98?= =?UTF-8?q?=EC=9D=B4=EC=A7=80=20(Qt=20GUI=20=EC=9B=B9=20=EB=B2=84=EC=A0=84?= =?UTF-8?q?)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend/app.py | 263 +++++++++ backend/scripts/perf_test.py | 68 +++ backend/scripts/test_pets_migration.py | 23 + backend/services/kakao_client.py | 83 +++ backend/templates/admin_pos_live.html | 750 +++++++++++++++++++++++++ 5 files changed, 1187 insertions(+) create mode 100644 backend/scripts/perf_test.py create mode 100644 backend/scripts/test_pets_migration.py create mode 100644 backend/templates/admin_pos_live.html diff --git a/backend/app.py b/backend/app.py index e3f8f7d..61058fd 100644 --- a/backend/app.py +++ b/backend/app.py @@ -4851,6 +4851,269 @@ def get_breeds(species): return jsonify({'success': True, 'breeds': ['기타']}) +# ═══════════════════════════════════════════════════════════════════════════════ +# POS 실시간 판매 조회 (Qt GUI 웹 버전) +# ═══════════════════════════════════════════════════════════════════════════════ + +@app.route('/admin/pos-live') +def admin_pos_live(): + """POS 실시간 판매 조회 페이지 (Qt GUI 웹 버전)""" + return render_template('admin_pos_live.html') + + +@app.route('/api/admin/pos-live') +def api_admin_pos_live(): + """ + 실시간 판매 내역 API (Qt GUI와 동일한 쿼리) + - MSSQL: SALE_MAIN, CD_SUNAB, SALE_SUB + - SQLite: claim_tokens, users, mileage_ledger + """ + date_str = request.args.get('date') + if not date_str: + date_str = datetime.now().strftime('%Y%m%d') + + mssql_conn = None + try: + # MSSQL 연결 + mssql_engine = db_manager.get_engine('PM_PRES') + mssql_conn = mssql_engine.raw_connection() + mssql_cursor = mssql_conn.cursor() + + # SQLite 연결 + sqlite_conn = db_manager.get_sqlite_connection() + sqlite_cursor = sqlite_conn.cursor() + + # 메인 쿼리: SALE_MAIN + CD_SUNAB 조인 + query = """ + SELECT + M.SL_NO_order, + M.InsertTime, + M.SL_MY_sale, + ISNULL(M.SL_NM_custom, '[비고객]') AS customer_name, + ISNULL(S.card_total, 0) AS card_total, + ISNULL(S.cash_total, 0) AS cash_total, + ISNULL(M.SL_MY_total, 0) AS total_amount, + ISNULL(M.SL_MY_discount, 0) AS discount, + S.cash_receipt_mode, + S.cash_receipt_num + FROM SALE_MAIN M + OUTER APPLY ( + SELECT TOP 1 + ISNULL(ETC_CARD, 0) + ISNULL(OTC_CARD, 0) AS card_total, + ISNULL(ETC_CASH, 0) + ISNULL(OTC_CASH, 0) AS cash_total, + nCASHINMODE AS cash_receipt_mode, + nAPPROVAL_NUM AS cash_receipt_num + FROM CD_SUNAB + WHERE PRESERIAL = M.SL_NO_order + ) S + WHERE M.SL_DT_appl = ? + ORDER BY M.InsertTime DESC + """ + + mssql_cursor.execute(query, date_str) + rows = mssql_cursor.fetchall() + + sales_list = [] + total_sales = 0 + + for row in rows: + order_no, insert_time, sale_amount, customer, card_total, cash_total, total_amount, discount, cash_receipt_mode, cash_receipt_num = row + + # 품목 수 조회 (SALE_SUB) + mssql_cursor.execute(""" + SELECT COUNT(*) FROM SALE_SUB WHERE SL_NO_order = ? + """, order_no) + item_count_row = mssql_cursor.fetchone() + item_count = item_count_row[0] if item_count_row else 0 + + # SQLite에서 QR 발행 여부 확인 + sqlite_cursor.execute(""" + SELECT id FROM claim_tokens WHERE transaction_id = ? + """, (order_no,)) + qr_record = sqlite_cursor.fetchone() + qr_issued = bool(qr_record) + + # SQLite에서 적립 사용자 조회 + sqlite_cursor.execute(""" + SELECT u.nickname, u.phone, ct.claimable_points + 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_name = claimed_user['nickname'] + claimed_phone = claimed_user['phone'] + claimed_points = claimed_user['claimable_points'] + else: + claimed_name = "" + claimed_phone = "" + claimed_points = 0 + + # 결제수단 판별 + card_amt = float(card_total) if card_total else 0.0 + cash_amt = float(cash_total) if cash_total else 0.0 + has_cash_receipt = ( + str(cash_receipt_mode or '').strip() == '1' + and str(cash_receipt_num or '').strip() != '' + ) + + if card_amt > 0 and cash_amt > 0: + pay_method = '카드+현금' + elif card_amt > 0: + pay_method = '카드' + elif cash_amt > 0: + pay_method = '현영' if has_cash_receipt else '현금' + else: + pay_method = '' + + paid = (card_amt + cash_amt) > 0 + disc_amt = float(discount) if discount else 0.0 + total_amt = float(total_amount) if total_amount else 0.0 + sale_amt = float(sale_amount) if sale_amount else 0.0 + + total_sales += sale_amt + + sales_list.append({ + 'order_no': order_no, + 'time': insert_time.strftime('%H:%M') if insert_time else '--:--', + 'amount': sale_amt, + 'discount': disc_amt, + 'total_before_dc': total_amt, + 'customer': customer, + 'pay_method': pay_method, + 'paid': paid, + 'item_count': item_count, + 'claimed_name': claimed_name, + 'claimed_phone': claimed_phone, + 'claimed_points': claimed_points, + 'qr_issued': qr_issued + }) + + return jsonify({ + 'success': True, + 'date': date_str, + 'count': len(sales_list), + 'total_sales': total_sales, + 'sales': sales_list + }) + + except Exception as e: + logging.error(f"POS 실시간 조회 오류: {e}") + return jsonify({'success': False, 'error': str(e)}), 500 + finally: + if mssql_conn: + mssql_conn.close() + + +@app.route('/api/admin/pos-live/detail/') +def api_admin_pos_live_detail(order_no): + """ + 판매 상세 조회 API (SALE_SUB 품목 목록) + """ + mssql_conn = None + try: + mssql_engine = db_manager.get_engine('PM_PRES') + mssql_conn = mssql_engine.raw_connection() + cursor = mssql_conn.cursor() + + # 품목 상세 조회 + cursor.execute(""" + SELECT + S.DrugCode AS drug_code, + ISNULL(G.GoodsName, '(약품명 없음)') AS product_name, + S.SL_NM_item AS quantity, + S.SL_NM_cost_a AS unit_price, + S.SL_TOTAL_PRICE AS total_price + FROM SALE_SUB S + LEFT JOIN PM_DRUG.dbo.CD_GOODS G ON S.DrugCode = G.DrugCode + WHERE S.SL_NO_order = ? + ORDER BY S.DrugCode + """, order_no) + + rows = cursor.fetchall() + items = [] + for row in rows: + items.append({ + 'drug_code': row[0], + 'product_name': row[1], + 'quantity': int(row[2]) if row[2] else 0, + 'unit_price': float(row[3]) if row[3] else 0, + 'total_price': float(row[4]) if row[4] else 0 + }) + + return jsonify({ + 'success': True, + 'order_no': order_no, + 'items': items + }) + + except Exception as e: + logging.error(f"판매 상세 조회 오류: {e}") + return jsonify({'success': False, 'error': str(e)}), 500 + finally: + if mssql_conn: + mssql_conn.close() + + +@app.route('/api/admin/user-mileage/') +def api_admin_user_mileage(phone): + """ + 회원 마일리지 내역 API + """ + try: + sqlite_conn = db_manager.get_sqlite_connection() + cursor = sqlite_conn.cursor() + + # 전화번호로 사용자 조회 + cursor.execute(""" + SELECT id, nickname, phone, mileage_balance, created_at + FROM users WHERE phone = ? + """, (phone,)) + user = cursor.fetchone() + + if not user: + return jsonify({'success': False, 'error': '등록되지 않은 회원입니다.'}), 404 + + # 적립 내역 조회 + cursor.execute(""" + SELECT points, balance_after, reason, description, created_at + FROM mileage_ledger + WHERE user_id = ? + ORDER BY created_at DESC + LIMIT 50 + """, (user['id'],)) + transactions = cursor.fetchall() + + history = [] + for tx in transactions: + history.append({ + 'points': tx['points'], + 'balance_after': tx['balance_after'], + 'reason': tx['reason'], + 'description': tx['description'], + 'created_at': tx['created_at'] + }) + + return jsonify({ + 'success': True, + 'user': { + 'id': user['id'], + 'nickname': user['nickname'], + 'phone': user['phone'], + 'mileage_balance': user['mileage_balance'], + 'created_at': user['created_at'] + }, + 'history': history + }) + + except Exception as e: + logging.error(f"회원 마일리지 조회 오류: {e}") + return jsonify({'success': False, 'error': str(e)}), 500 + + if __name__ == '__main__': import os diff --git a/backend/scripts/perf_test.py b/backend/scripts/perf_test.py new file mode 100644 index 0000000..114f526 --- /dev/null +++ b/backend/scripts/perf_test.py @@ -0,0 +1,68 @@ +# -*- coding: utf-8 -*- +"""APC 매칭 성능 측정""" +import sys, io, time +sys.stdout = io.TextIOWrapper(sys.stdout.buffer, encoding='utf-8') +sys.path.insert(0, 'c:\\Users\\청춘약국\\source\\pharmacy-pos-qr-system\\backend') + +from db.dbsetup import get_db_session +from sqlalchemy import text, create_engine + +print('=== APC 매칭 성능 측정 ===\n') + +# 1. MSSQL: 동물약 + APC 조회 +start = time.time() +session = get_db_session('PM_DRUG') +result = session.execute(text(""" + SELECT G.DrugCode, G.GoodsName, + (SELECT TOP 1 U.CD_CD_BARCODE FROM CD_ITEM_UNIT_MEMBER U + WHERE U.DRUGCODE = G.DrugCode AND U.CD_CD_BARCODE LIKE '023%') AS APC_CODE + FROM CD_GOODS G + WHERE G.POS_BOON = '010103' AND G.GoodsSelCode = 'B' +""")) +mssql_rows = list(result) +no_apc = [r for r in mssql_rows if not r.APC_CODE] +has_apc = [r for r in mssql_rows if r.APC_CODE] +mssql_time = time.time() - start +print(f'1. MSSQL 동물약 조회: {mssql_time:.3f}초') +print(f' - 총 제품: {len(mssql_rows)}개') +print(f' - APC 있음: {len(has_apc)}개 ✅') +print(f' - APC 없음: {len(no_apc)}개 ❌') + +# 2. PostgreSQL 연결 + 매칭 검색 +pg = create_engine('postgresql://admin:trajet6640@192.168.0.87:5432/apdb_master').connect() + +# 샘플 매칭 테스트 +sample_count = min(5, len(no_apc)) +start = time.time() +match_count = 0 +for drug in no_apc[:sample_count]: + search_name = drug.GoodsName.replace('(판)', '').split('(')[0].strip() + res = pg.execute(text(""" + SELECT apc, product_name FROM apc + WHERE product_name ILIKE :p LIMIT 5 + """), {'p': f'%{search_name}%'}) + if list(res): + match_count += 1 +pg_search_time = time.time() - start +per_search = pg_search_time / sample_count if sample_count > 0 else 0 +print(f'\n2. PostgreSQL 매칭 검색: {pg_search_time:.3f}초 ({sample_count}개 샘플)') +print(f' - 건당 소요: {per_search*1000:.1f}ms') +print(f' - 매칭 성공: {match_count}/{sample_count}') +print(f' - 예상 전체: {per_search * len(no_apc):.1f}초 ({len(no_apc)}개)') + +# 3. APC 테이블 통계 +start = time.time() +total_apc = pg.execute(text("SELECT COUNT(*) FROM apc")).scalar() +with_image = pg.execute(text("SELECT COUNT(*) FROM apc WHERE image_url1 IS NOT NULL AND image_url1 != ''")).scalar() +pg.close() +print(f'\n3. APDB 통계:') +print(f' - 전체 APC: {total_apc:,}개') +print(f' - 이미지 있음: {with_image:,}개 ({with_image/total_apc*100:.1f}%)') + +# 4. CD_ITEM_UNIT_MEMBER 구조 확인 +print(f'\n4. 현재 APC 매핑 상태:') +for r in has_apc[:5]: + print(f' ✅ {r.GoodsName[:25]:<25} → {r.APC_CODE}') + +session.close() +print('\n=== 측정 완료 ===') diff --git a/backend/scripts/test_pets_migration.py b/backend/scripts/test_pets_migration.py new file mode 100644 index 0000000..9ebf306 --- /dev/null +++ b/backend/scripts/test_pets_migration.py @@ -0,0 +1,23 @@ +# -*- coding: utf-8 -*- +"""pets 테이블 마이그레이션 테스트""" +import sys, io +sys.stdout = io.TextIOWrapper(sys.stdout.buffer, encoding='utf-8') +sys.path.insert(0, 'c:\\Users\\청춘약국\\source\\pharmacy-pos-qr-system\\backend') + +from db.dbsetup import db_manager + +# SQLite 연결 (마이그레이션 자동 실행) +conn = db_manager.get_sqlite_connection() +cursor = conn.cursor() + +# pets 테이블 확인 +cursor.execute("SELECT name FROM sqlite_master WHERE type='table' AND name='pets'") +if cursor.fetchone(): + print('✅ pets 테이블 생성 완료') + cursor.execute('PRAGMA table_info(pets)') + columns = cursor.fetchall() + print('\n컬럼 목록:') + for col in columns: + print(f' - {col[1]} ({col[2]})') +else: + print('❌ pets 테이블 없음') diff --git a/backend/services/kakao_client.py b/backend/services/kakao_client.py index a6313ad..e209ca6 100644 --- a/backend/services/kakao_client.py +++ b/backend/services/kakao_client.py @@ -98,6 +98,89 @@ class KakaoAPIClient: 'error_description': f'Invalid JSON response: {e}' } + def refresh_access_token(self, refresh_token: str) -> Tuple[bool, Dict[str, Any]]: + """Refresh Token으로 Access Token 갱신""" + url = f"{self.auth_base_url}/oauth/token" + + data = { + 'grant_type': 'refresh_token', + 'client_id': self.client_id, + 'refresh_token': refresh_token, + } + if self.client_secret: + data['client_secret'] = self.client_secret + + try: + headers = {'Content-Type': 'application/x-www-form-urlencoded'} + response = self.session.post(url, data=data, headers=headers) + + logger.info(f"카카오 토큰 갱신 응답 상태: {response.status_code}") + response.raise_for_status() + + token_data = response.json() + if 'expires_in' in token_data: + expires_at = datetime.now() + timedelta(seconds=token_data['expires_in']) + token_data['expires_at'] = expires_at.isoformat() + + return True, token_data + + except requests.exceptions.RequestException as e: + logger.error(f"카카오 토큰 갱신 실패: {e}") + error_details = { + 'error': 'token_refresh_failed', + 'error_description': f'Failed to refresh access token: {e}' + } + try: + if hasattr(e, 'response') and e.response is not None: + kakao_error = e.response.json() + logger.error(f"카카오 API 오류: {kakao_error}") + error_details.update(kakao_error) + except Exception: + pass + return False, error_details + + def get_user_info_with_refresh( + self, + access_token: str, + refresh_token: str, + token_expires_at: str = None + ) -> Tuple[bool, Dict[str, Any], Dict[str, Any]]: + """저장된 토큰으로 사용자 정보 조회 (만료 시 자동 갱신) + + Returns: + (성공여부, 사용자정보/에러, 갱신된 토큰 데이터 또는 빈 dict) + """ + new_token_data = {} + + # 만료 확인: 5분 이내면 미리 갱신 + if token_expires_at: + try: + expires = datetime.fromisoformat(token_expires_at) + if datetime.now() >= expires - timedelta(minutes=5): + logger.info("Access token 만료 임박, 갱신 시도") + success, refreshed = self.refresh_access_token(refresh_token) + if success: + access_token = refreshed['access_token'] + new_token_data = refreshed + else: + return False, refreshed, {} + except (ValueError, TypeError) as e: + logger.warning(f"token_expires_at 파싱 실패, 기존 토큰으로 시도: {e}") + + # 사용자 정보 조회 + success, user_info = self.get_user_info(access_token) + + if not success and refresh_token: + # 실패 시 갱신 후 재시도 + logger.info("사용자 정보 조회 실패, 토큰 갱신 후 재시도") + refresh_ok, refreshed = self.refresh_access_token(refresh_token) + if refresh_ok: + access_token = refreshed['access_token'] + new_token_data = refreshed + success, user_info = self.get_user_info(access_token) + + return success, user_info, new_token_data + def get_user_info(self, access_token: str) -> Tuple[bool, Dict[str, Any]]: """Access Token으로 사용자 정보 조회""" url = f"{self.api_base_url}/v2/user/me" diff --git a/backend/templates/admin_pos_live.html b/backend/templates/admin_pos_live.html new file mode 100644 index 0000000..3daeb38 --- /dev/null +++ b/backend/templates/admin_pos_live.html @@ -0,0 +1,750 @@ + + + + + + 실시간 판매 조회 - 청춘약국 + + + + + + +
+ +

📊 실시간 판매 조회

+

POS 판매 내역을 실시간으로 확인합니다 (Qt GUI 웹 버전)

+
+ +
+ +
+
+ + + +
+
+ + +
+
+ + +
+
+
총 판매 건수
+
-
+
+
+
총 매출액
+
-
+
+
+
QR 발행
+
-
+
+
+
적립 완료
+
-
+
+
+ + +
+
+ 판매 내역 + +
+
+ + + + + + + + + + + + + + + + + +
시간금액고객결제품목QR적립
+
+
+ 데이터 로딩 중... +
+
+
+
+
+ + +
+
+
+ 판매 상세 + +
+
+ +
+
+ + + +