From 7843ca8fcf47c295b6ee42209714834917878717 Mon Sep 17 00:00:00 2001 From: thug0bin Date: Fri, 27 Feb 2026 15:08:09 +0900 Subject: [PATCH] =?UTF-8?q?feat:=20=ED=9A=8C=EC=9B=90=20=EC=83=81=EC=84=B8?= =?UTF-8?q?=20=EB=AA=A8=EB=8B=AC=20=EA=B5=AC=ED=98=84=20(=EB=A7=88?= =?UTF-8?q?=EC=9D=BC=EB=A6=AC=EC=A7=80=20+=20POS=20=EC=9D=B4=EB=A0=A5)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - /api/members/history/: 통합 이력 조회 API - 마일리지 적립/사용 내역 (SQLite) - POS 구매 이력 (MSSQL - 전화번호→고객코드 매핑) - 세련된 UI: 탭 전환, 거래 카드, 구매 카드 - 상세에서 바로 메시지 발송 가능 --- backend/app.py | 140 ++++++++++ backend/templates/admin_members.html | 367 ++++++++++++++++++++++++++- 2 files changed, 503 insertions(+), 4 deletions(-) diff --git a/backend/app.py b/backend/app.py index 8afc171..86ba9c4 100644 --- a/backend/app.py +++ b/backend/app.py @@ -3004,6 +3004,146 @@ def api_member_detail(cuscode): return jsonify({'success': False, 'error': str(e)}), 500 +@app.route('/api/members/history/') +def api_member_history(phone): + """ + 회원 구매 이력 통합 조회 API + - 마일리지 적립/사용 내역 (SQLite) + - POS 구매 이력 (MSSQL) + """ + try: + # 전화번호 정규화 + phone = phone.replace('-', '').replace(' ', '') + + result = { + 'success': True, + 'phone': phone, + 'mileage': None, + 'purchases': [] + } + + # 1. 마일리지 내역 조회 (SQLite) + 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 user: + user_id = user['id'] + + # 적립/사용 내역 조회 + cursor.execute(""" + SELECT + ml.points, ml.balance_after, ml.reason, + ml.description, ml.transaction_id, ml.created_at + FROM mileage_ledger ml + WHERE ml.user_id = ? + ORDER BY ml.created_at DESC + LIMIT 50 + """, (user_id,)) + transactions = cursor.fetchall() + + result['mileage'] = { + 'user_id': user_id, + 'name': user['nickname'] or '', + 'phone': user['phone'], + 'balance': user['mileage_balance'] or 0, + 'member_since': user['created_at'], + 'transactions': [{ + 'points': t['points'], + 'balance_after': t['balance_after'], + 'reason': t['reason'], + 'description': t['description'], + 'transaction_id': t['transaction_id'], + 'created_at': t['created_at'] + } for t in transactions] + } + except Exception as e: + logging.warning(f"마일리지 조회 실패: {e}") + + # 2. POS 구매 이력 조회 (MSSQL) + try: + base_session = db_manager.get_session('PM_BASE') + pres_session = db_manager.get_session('PM_PRES') + drug_session = db_manager.get_session('PM_DRUG') + + # 전화번호로 고객코드 조회 + cuscode_query = text(""" + SELECT TOP 1 CUSCODE, PANAME + FROM CD_PERSON + WHERE REPLACE(REPLACE(PHONE, '-', ''), ' ', '') = :phone + OR REPLACE(REPLACE(TEL_NO, '-', ''), ' ', '') = :phone + OR REPLACE(REPLACE(PHONE2, '-', ''), ' ', '') = :phone + """) + cus_row = base_session.execute(cuscode_query, {'phone': phone}).fetchone() + + if cus_row: + cuscode = cus_row.CUSCODE + result['pos_customer'] = { + 'cuscode': cuscode, + 'name': cus_row.PANAME + } + + # 구매 이력 조회 (최근 30일) + purchase_query = text(""" + SELECT + M.SL_NO_order as order_no, + M.SL_DT_appl as order_date, + M.SL_MY_total as total_amount, + M.SL_MY_discount as discount + FROM SALE_MAIN M + WHERE M.SL_CD_custom = :cuscode + AND M.SL_DT_appl >= CONVERT(VARCHAR(8), DATEADD(DAY, -30, GETDATE()), 112) + ORDER BY M.SL_DT_appl DESC, M.SL_NO_order DESC + """) + orders = pres_session.execute(purchase_query, {'cuscode': cuscode}).fetchall() + + purchases = [] + for order in orders[:20]: # 최대 20건 + # 주문 상세 (품목) + items_query = text(""" + SELECT + S.DrugCode, + G.GoodsName, + S.QUAN as quantity, + S.SL_TOTAL_PRICE as price + FROM SALE_SUB S + LEFT JOIN PM_DRUG.dbo.CD_GOODS G ON S.DrugCode = G.DrugCode + WHERE S.SL_NO_order = :order_no + """) + items = pres_session.execute(items_query, {'order_no': order.order_no}).fetchall() + + purchases.append({ + 'order_no': order.order_no, + 'date': order.order_date, + 'total': float(order.total_amount) if order.total_amount else 0, + 'discount': float(order.discount) if order.discount else 0, + 'items': [{ + 'drug_code': item.DrugCode, + 'name': item.GoodsName or '알 수 없음', + 'quantity': float(item.quantity) if item.quantity else 1, + 'price': float(item.price) if item.price else 0 + } for item in items] + }) + + result['purchases'] = purchases + + except Exception as e: + logging.warning(f"POS 구매 이력 조회 실패: {e}") + + return jsonify(result) + + except Exception as e: + logging.error(f"회원 이력 조회 오류: {e}") + return jsonify({'success': False, 'error': str(e)}), 500 + + # ============================================================================= # 알림톡/SMS 발송 API # ============================================================================= diff --git a/backend/templates/admin_members.html b/backend/templates/admin_members.html index 928cc2c..020134d 100644 --- a/backend/templates/admin_members.html +++ b/backend/templates/admin_members.html @@ -320,6 +320,194 @@ .modal-btn.confirm { background: #6366f1; color: #fff; } .modal-btn.confirm:hover { background: #4f46e5; } + /* ── 회원 상세 모달 ── */ + .detail-modal { + max-width: 600px; + max-height: 85vh; + overflow: hidden; + display: flex; + flex-direction: column; + } + .detail-header { + background: linear-gradient(135deg, #059669, #10b981); + margin: -24px -24px 0; + padding: 24px; + border-radius: 16px 16px 0 0; + color: #fff; + } + .detail-name { + font-size: 22px; + font-weight: 700; + margin-bottom: 6px; + } + .detail-phone { + font-size: 15px; + opacity: 0.9; + } + .detail-balance { + margin-top: 12px; + padding: 12px 16px; + background: rgba(255,255,255,0.15); + border-radius: 10px; + display: flex; + justify-content: space-between; + align-items: center; + } + .detail-balance-label { + font-size: 13px; + opacity: 0.9; + } + .detail-balance-value { + font-size: 24px; + font-weight: 700; + } + .detail-tabs { + display: flex; + border-bottom: 2px solid #e2e8f0; + margin: 20px -24px 0; + padding: 0 24px; + } + .detail-tab { + padding: 12px 20px; + font-size: 14px; + font-weight: 600; + color: #64748b; + cursor: pointer; + border-bottom: 2px solid transparent; + margin-bottom: -2px; + transition: all 0.2s; + } + .detail-tab:hover { color: #10b981; } + .detail-tab.active { + color: #10b981; + border-bottom-color: #10b981; + } + .detail-content { + flex: 1; + overflow-y: auto; + padding: 20px 0; + max-height: 400px; + } + .detail-empty { + text-align: center; + padding: 40px; + color: #94a3b8; + } + .detail-loading { + text-align: center; + padding: 40px; + color: #64748b; + } + + /* 거래 카드 */ + .tx-card { + background: #f8fafc; + border-radius: 12px; + padding: 16px; + margin-bottom: 12px; + border-left: 4px solid #10b981; + } + .tx-card.negative { border-left-color: #f59e0b; } + .tx-header { + display: flex; + justify-content: space-between; + align-items: flex-start; + margin-bottom: 8px; + } + .tx-date { + font-size: 12px; + color: #64748b; + } + .tx-points { + font-size: 18px; + font-weight: 700; + color: #10b981; + } + .tx-points.negative { color: #f59e0b; } + .tx-desc { + font-size: 13px; + color: #475569; + } + .tx-items { + margin-top: 10px; + padding-top: 10px; + border-top: 1px dashed #e2e8f0; + } + .tx-item { + display: flex; + justify-content: space-between; + font-size: 12px; + color: #64748b; + padding: 4px 0; + } + .tx-item-name { + flex: 1; + } + .tx-item-qty { + color: #94a3b8; + margin: 0 12px; + } + .tx-item-price { + font-weight: 500; + color: #475569; + } + + /* 구매 카드 */ + .purchase-card { + background: #fff; + border: 1px solid #e2e8f0; + border-radius: 12px; + padding: 16px; + margin-bottom: 12px; + } + .purchase-header { + display: flex; + justify-content: space-between; + align-items: center; + margin-bottom: 12px; + } + .purchase-date { + font-size: 13px; + color: #64748b; + } + .purchase-total { + font-size: 16px; + font-weight: 700; + color: #1e293b; + } + .purchase-items { + border-top: 1px solid #f1f5f9; + padding-top: 12px; + } + .purchase-item { + display: flex; + justify-content: space-between; + align-items: center; + padding: 6px 0; + font-size: 13px; + } + .purchase-item-name { + flex: 1; + color: #334155; + } + .purchase-item-qty { + color: #94a3b8; + margin: 0 16px; + font-size: 12px; + } + .purchase-item-price { + color: #64748b; + font-weight: 500; + } + + .detail-footer { + padding-top: 16px; + border-top: 1px solid #e2e8f0; + display: flex; + gap: 12px; + justify-content: flex-end; + } + /* ── 반응형 ── */ @media (max-width: 768px) { .search-box { flex-direction: column; } @@ -410,6 +598,31 @@ + + +