diff --git a/backend/app.py b/backend/app.py index 62014c4..c0e04ca 100644 --- a/backend/app.py +++ b/backend/app.py @@ -6467,7 +6467,8 @@ def api_admin_pos_live(): M.SL_NO_order, M.InsertTime, M.SL_MY_sale, - ISNULL(M.SL_NM_custom, '[비고객]') AS customer_name, + ISNULL(M.SL_NM_custom, '') AS customer_name, + M.SL_CD_custom AS customer_code, 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, @@ -6495,7 +6496,7 @@ def api_admin_pos_live(): 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 + order_no, insert_time, sale_amount, customer, customer_code, card_total, cash_total, total_amount, discount, cash_receipt_mode, cash_receipt_num = row # 품목 수 조회 (SALE_SUB) mssql_cursor.execute(""" @@ -6579,7 +6580,8 @@ def api_admin_pos_live(): 'amount': sale_amt, 'discount': disc_amt, 'total_before_dc': total_amt, - 'customer': customer, + 'customer': customer if customer else '', + 'customer_code': customer_code if customer_code else '0000000000', 'pay_method': pay_method, 'paid': paid, 'item_count': item_count, @@ -6665,6 +6667,213 @@ def api_admin_pos_live_detail(order_no): mssql_conn.close() +@app.route('/api/customers//mileage') +def api_customer_mileage(cus_code): + """ + 고객 마일리지 조회 API (비동기) + - CD_PERSON에서 이름+전화번호 조회 + - SQLite users와 이름+전화뒤4자리로 매칭 + """ + if not cus_code or cus_code == '0000000000': + return jsonify({'success': False, 'mileage': None}) + + mssql_conn = None + try: + # 1. CD_PERSON에서 이름, 전화번호 조회 + mssql_engine = db_manager.get_engine('PM_BASE') + mssql_conn = mssql_engine.raw_connection() + cursor = mssql_conn.cursor() + + cursor.execute(""" + SELECT PANAME, PHONE, TEL_NO, PHONE2 + FROM CD_PERSON + WHERE CUSCODE = ? + """, cus_code) + row = cursor.fetchone() + + if not row: + return jsonify({'success': False, 'mileage': None}) + + name, phone1, phone2, phone3 = row + phone = phone1 or phone2 or phone3 or '' + phone_digits = ''.join(c for c in phone if c.isdigit()) + last4 = phone_digits[-4:] if len(phone_digits) >= 4 else '' + + if not name or not last4: + return jsonify({'success': False, 'mileage': None}) + + # 2. SQLite에서 이름+전화뒤4자리로 매칭 + sqlite_conn = db_manager.get_sqlite_connection() + sqlite_cursor = sqlite_conn.cursor() + + sqlite_cursor.execute(""" + SELECT nickname, phone, mileage_balance + FROM users + """) + + for user in sqlite_cursor.fetchall(): + user_phone = ''.join(c for c in (user['phone'] or '') if c.isdigit()) + user_last4 = user_phone[-4:] if len(user_phone) >= 4 else '' + + if user['nickname'] == name and user_last4 == last4: + return jsonify({ + 'success': True, + 'mileage': user['mileage_balance'] or 0, + 'name': name + }) + + return jsonify({'success': False, 'mileage': None}) + + 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/customers/search') +def api_customers_search(): + """ + 고객 검색 API (CD_PERSON + 최근 조제/구매 활동) + - name: 검색할 이름 + - 결과: 최근 활동순 정렬, 생년월일 포함 + """ + name = request.args.get('name', '').strip() + if not name or len(name) < 2: + return jsonify({'success': False, 'error': '이름을 2자 이상 입력하세요.'}), 400 + + mssql_conn = None + try: + mssql_engine = db_manager.get_engine('PM_BASE') + mssql_conn = mssql_engine.raw_connection() + cursor = mssql_conn.cursor() + + # CD_PERSON에서 이름으로 검색 + 최근 조제/구매일 조인 + cursor.execute(""" + SELECT DISTINCT + p.CUSCODE, + p.PANAME, + LEFT(p.PANUM, 6) AS birth_date, + p.PHONE, + (SELECT MAX(Indate) FROM PM_PRES.dbo.PS_main WHERE CusCode = p.CUSCODE) AS last_rx, + (SELECT MAX(InsertTime) FROM PM_PRES.dbo.SALE_MAIN WHERE SL_CD_custom = p.CUSCODE) AS last_sale + FROM CD_PERSON p + WHERE p.PANAME LIKE ? + ORDER BY p.PANAME + """, f'%{name}%') + + rows = cursor.fetchall() + results = [] + + for row in rows: + cus_code, pa_name, birth, phone, last_rx, last_sale = row + + # 최근 활동일 계산 + last_activity = None + activity_type = None + + if last_rx and last_sale: + # 둘 다 있으면 더 최근 것 + rx_date = datetime.strptime(last_rx, '%Y%m%d') if isinstance(last_rx, str) else last_rx + if isinstance(last_sale, datetime): + if rx_date > last_sale: + last_activity = rx_date + activity_type = '조제' + else: + last_activity = last_sale + activity_type = '구매' + else: + last_activity = rx_date + activity_type = '조제' + elif last_rx: + last_activity = datetime.strptime(last_rx, '%Y%m%d') if isinstance(last_rx, str) else last_rx + activity_type = '조제' + elif last_sale: + last_activity = last_sale + activity_type = '구매' + + # 며칠 전 계산 + days_ago = None + if last_activity: + if isinstance(last_activity, datetime): + days_ago = (datetime.now() - last_activity).days + else: + days_ago = (datetime.now() - datetime.strptime(str(last_activity)[:8], '%Y%m%d')).days + + results.append({ + 'cus_code': cus_code, + 'name': pa_name, + 'birth': birth if birth else '', + 'phone': phone if phone else '', + 'activity_type': activity_type, + 'days_ago': days_ago, + 'last_activity': last_activity.strftime('%Y-%m-%d') if last_activity else None + }) + + # 최근 활동순 정렬 (활동 있는 것 먼저, 그 중 최근 것 먼저) + results.sort(key=lambda x: (x['days_ago'] is None, x['days_ago'] if x['days_ago'] is not None else 9999)) + + return jsonify({ + 'success': True, + 'results': results[:50] # 최대 50개 + }) + + 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/pos-live//customer', methods=['PUT']) +def api_pos_live_update_customer(order_no): + """ + 판매 건의 고객 정보 업데이트 API + - cus_code: 고객 코드 (CD_PERSON.CUSCODE) + - cus_name: 고객 이름 + """ + data = request.get_json() + if not data: + return jsonify({'success': False, 'error': 'JSON 데이터 필요'}), 400 + + cus_code = data.get('cus_code', '').strip() + cus_name = data.get('cus_name', '').strip() + + if not cus_code or not cus_name: + return jsonify({'success': False, 'error': '고객 코드와 이름 필요'}), 400 + + mssql_conn = None + try: + mssql_engine = db_manager.get_engine('PM_PRES') + mssql_conn = mssql_engine.raw_connection() + cursor = mssql_conn.cursor() + + # SALE_MAIN 업데이트 + cursor.execute(""" + UPDATE SALE_MAIN + SET SL_CD_custom = ?, SL_NM_custom = ? + WHERE SL_NO_order = ? + """, cus_code, cus_name, order_no) + + mssql_conn.commit() + + return jsonify({ + 'success': True, + 'message': f'고객 정보가 {cus_name}({cus_code})으로 업데이트되었습니다.' + }) + + except Exception as e: + logging.error(f"고객 정보 업데이트 오류: {e}") + if mssql_conn: + mssql_conn.rollback() + 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): """ diff --git a/backend/templates/admin_pos_live.html b/backend/templates/admin_pos_live.html index 235150a..9cd007c 100644 --- a/backend/templates/admin_pos_live.html +++ b/backend/templates/admin_pos_live.html @@ -263,11 +263,159 @@ font-weight: 600; color: #1e293b; } - .customer { + .customer-badge { + display: inline-block; + padding: 4px 10px; + border-radius: 6px; + font-weight: 500; + cursor: pointer; + transition: all 0.2s; + } + .customer-badge:hover { + transform: scale(1.05); + } + .customer-badge.has-name { + background: #dbeafe; + color: #1e40af; + } + .customer-badge.has-name:hover { + background: #bfdbfe; + } + .customer-badge.no-name { + background: #f1f5f9; + color: #94a3b8; + border: 1px dashed #cbd5e1; + } + .customer-badge.no-name:hover { + background: #e2e8f0; + border-color: #3b82f6; + color: #2563eb; + } + .mileage-badge { + display: inline-block; + padding: 2px 6px; + margin-left: 6px; + background: linear-gradient(135deg, #fbbf24, #f59e0b); + color: white; + border-radius: 4px; + font-size: 11px; + font-weight: 600; + } + + /* 고객 검색 모달 */ + .customer-modal { + display: none; + position: fixed; + top: 0; left: 0; right: 0; bottom: 0; + background: rgba(0,0,0,0.6); + z-index: 2000; + align-items: center; + justify-content: center; + } + .customer-modal.show { display: flex; } + .customer-modal-content { + background: #fff; + border-radius: 16px; + padding: 24px; + max-width: 500px; + width: 90%; + max-height: 80vh; + overflow: hidden; + display: flex; + flex-direction: column; + } + .customer-modal h3 { + margin: 0 0 16px 0; + color: #1e40af; + font-size: 18px; + } + .customer-search-box { + display: flex; + gap: 8px; + margin-bottom: 16px; + } + .customer-search-input { + flex: 1; + padding: 12px; + border: 2px solid #e2e8f0; + border-radius: 8px; + font-size: 14px; + } + .customer-search-input:focus { + outline: none; + border-color: #3b82f6; + } + .customer-search-btn { + padding: 12px 20px; + background: #3b82f6; + color: white; + border: none; + border-radius: 8px; + cursor: pointer; font-weight: 500; } - .customer.non-member { + .customer-search-btn:hover { + background: #2563eb; + } + .customer-results { + flex: 1; + overflow-y: auto; + max-height: 400px; + } + .customer-result-item { + padding: 12px; + border: 1px solid #e2e8f0; + border-radius: 8px; + margin-bottom: 8px; + cursor: pointer; + transition: all 0.2s; + } + .customer-result-item:hover { + background: #f0f9ff; + border-color: #3b82f6; + } + .customer-result-name { + font-weight: 600; + color: #1e293b; + } + .customer-result-birth { + font-size: 12px; + color: #64748b; + margin-left: 8px; + } + .customer-result-activity { + font-size: 12px; color: #94a3b8; + margin-top: 4px; + } + .customer-result-activity.recent { + color: #22c55e; + } + .customer-modal-close { + margin-top: 16px; + padding: 10px 20px; + background: #f1f5f9; + color: #64748b; + border: none; + border-radius: 8px; + cursor: pointer; + font-weight: 500; + } + .customer-modal-close:hover { + background: #e2e8f0; + } + .customer-no-results { + text-align: center; + color: #94a3b8; + padding: 40px 20px; + } + .customer-order-info { + background: #f0f9ff; + border-radius: 8px; + padding: 12px; + margin-bottom: 16px; + font-size: 13px; + color: #64748b; } /* 결제수단 뱃지 */ @@ -917,7 +1065,13 @@ const rows = data.sales.map((sale, idx) => { const payBadge = getPayBadge(sale.pay_method); - const customerClass = sale.customer === '[비고객]' ? 'customer non-member' : 'customer'; + const hasCustomer = sale.customer && sale.customer !== '[비고객]' && sale.customer_code !== '0000000000'; + const mileageSpan = hasCustomer + ? `` + : ''; + const customerBadge = hasCustomer + ? `${escapeHtml(sale.customer)}${mileageSpan}` + : `미입력`; const qrIcon = sale.qr_issued ? '' : ''; @@ -944,7 +1098,7 @@ ${sale.time} ₩${Math.floor(sale.amount).toLocaleString()} - ${sale.customer} + ${customerBadge} ${payBadge} ${sale.item_count} ${qrIcon} @@ -955,6 +1109,37 @@ document.getElementById('salesTable').innerHTML = rows; updateSelectedCount(); + + // 비동기로 마일리지 조회 + fetchMileagesAsync(data.sales); + } + + async function fetchMileagesAsync(sales) { + // 고객코드 있는 건들만 수집 (중복 제거) + const cusCodes = [...new Set( + sales + .filter(s => s.customer_code && s.customer_code !== '0000000000') + .map(s => s.customer_code) + )]; + + // 각 고객코드에 대해 비동기 조회 + for (const code of cusCodes) { + try { + const res = await fetch(`/api/customers/${code}/mileage`); + const data = await res.json(); + + if (data.success && data.mileage !== null) { + // 해당 코드의 모든 mileage-badge 업데이트 + const badges = document.querySelectorAll(`#mileage-${code}`); + badges.forEach(badge => { + badge.textContent = data.mileage.toLocaleString() + 'P'; + badge.style.display = 'inline-block'; + }); + } + } catch (err) { + console.warn(`마일리지 조회 실패 (${code}):`, err); + } + } } function getPayBadge(method) { @@ -1481,6 +1666,149 @@ } `; document.head.appendChild(toastStyle); + + // ═══════════════════════════════════════════════════════════════ + // 고객 검색/매핑 모달 + // ═══════════════════════════════════════════════════════════════ + + let currentOrderNo = null; + let currentCustomerName = ''; + + function escapeHtml(str) { + if (!str) return ''; + return str.replace(/&/g, '&') + .replace(//g, '>') + .replace(/"/g, '"') + .replace(/'/g, '''); + } + + function openCustomerModal(orderNo, customerName, customerCode) { + currentOrderNo = orderNo; + currentCustomerName = customerName; + + document.getElementById('customerOrderInfo').textContent = + `주문번호: ${orderNo} | 현재: ${customerName || '미입력'}`; + document.getElementById('customerSearchInput').value = customerName || ''; + document.getElementById('customerResults').innerHTML = + '
이름을 검색하세요
'; + + document.getElementById('customerModal').classList.add('show'); + document.getElementById('customerSearchInput').focus(); + } + + function closeCustomerModal() { + document.getElementById('customerModal').classList.remove('show'); + currentOrderNo = null; + } + + async function searchCustomers() { + const name = document.getElementById('customerSearchInput').value.trim(); + if (name.length < 2) { + document.getElementById('customerResults').innerHTML = + '
2자 이상 입력하세요
'; + return; + } + + try { + const res = await fetch(`/api/customers/search?name=${encodeURIComponent(name)}`); + const data = await res.json(); + + if (!data.success) { + document.getElementById('customerResults').innerHTML = + `
${data.error}
`; + return; + } + + if (data.results.length === 0) { + document.getElementById('customerResults').innerHTML = + '
검색 결과가 없습니다
'; + return; + } + + const html = data.results.map(c => { + const birthDisplay = c.birth + ? `(${c.birth.substring(0,2)}.${c.birth.substring(2,4)}.${c.birth.substring(4,6)})` + : ''; + let activityHtml = ''; + if (c.activity_type && c.days_ago !== null) { + const isRecent = c.days_ago <= 30; + activityHtml = `
+ ${c.activity_type === '조제' ? '📋' : '🛒'} + ${c.activity_type} ${c.days_ago === 0 ? '오늘' : c.days_ago + '일 전'} +
`; + } else { + activityHtml = '
활동 기록 없음
'; + } + + return ` +
+ ${escapeHtml(c.name)} + ${birthDisplay} + ${activityHtml} +
+ `; + }).join(''); + + document.getElementById('customerResults').innerHTML = html; + + } catch (err) { + document.getElementById('customerResults').innerHTML = + `
오류: ${err.message}
`; + } + } + + async function selectCustomer(cusCode, cusName) { + if (!currentOrderNo) return; + + try { + const res = await fetch(`/api/pos-live/${currentOrderNo}/customer`, { + method: 'PUT', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ cus_code: cusCode, cus_name: cusName }) + }); + const data = await res.json(); + + if (data.success) { + showToast(`${cusName}님으로 업데이트됨`, 'success'); + closeCustomerModal(); + loadSales(); // 테이블 새로고침 + } else { + showToast('업데이트 실패: ' + data.error, 'error'); + } + } catch (err) { + showToast('오류: ' + err.message, 'error'); + } + } + + // Enter 키로 검색 + document.addEventListener('DOMContentLoaded', () => { + document.getElementById('customerSearchInput')?.addEventListener('keypress', e => { + if (e.key === 'Enter') searchCustomers(); + }); + }); + + // 모달 외부 클릭 시 닫기 + document.getElementById('customerModal')?.addEventListener('click', e => { + if (e.target.id === 'customerModal') closeCustomerModal(); + }); + + +
+
+

👤 고객 매핑

+
주문번호: -
+ +
+
이름을 검색하세요
+
+ +
+