""" Flask 웹 서버 - QR 마일리지 적립 간편 적립: 전화번호 + 이름만 입력 """ import sys import os # 현재 디렉토리를 Python path에 추가 (PM2 실행 시 utils 모듈 찾기 위함) sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) # Windows 콘솔 UTF-8 강제 (한글 깨짐 방지) if sys.platform == 'win32': import io sys.stdout = io.TextIOWrapper(sys.stdout.buffer, encoding='utf-8') sys.stderr = io.TextIOWrapper(sys.stderr.buffer, encoding='utf-8') os.environ.setdefault('PYTHONIOENCODING', 'utf-8') from flask import Flask, request, render_template, render_template_string, jsonify, redirect, url_for, session import hashlib import base64 import secrets from datetime import datetime, timezone, timedelta import logging from sqlalchemy import text from dotenv import load_dotenv import json import time import sqlite3 from pathlib import Path # 환경 변수 로드 (명시적 경로) env_path = Path(__file__).parent / '.env' load_dotenv(dotenv_path=env_path) # OpenAI import try: from openai import OpenAI, OpenAIError, RateLimitError, APITimeoutError OPENAI_AVAILABLE = True except ImportError: OPENAI_AVAILABLE = False logging.warning("OpenAI 라이브러리가 설치되지 않았습니다. AI 분석 기능을 사용할 수 없습니다.") # Path setup BACKEND_DIR = os.path.dirname(os.path.abspath(__file__)) sys.path.insert(0, BACKEND_DIR) from db.dbsetup import DatabaseManager # OTC 라벨 프린터 import try: from utils.otc_label_printer import generate_preview_image, print_otc_label OTC_LABEL_AVAILABLE = True except ImportError as e: OTC_LABEL_AVAILABLE = False logging.warning(f"OTC 라벨 프린터 모듈 로드 실패: {e}") app = Flask(__name__) app.secret_key = 'pharmacy-qr-mileage-secret-key-2026' # 세션 설정 (PWA 자동적립 지원) app.config['SESSION_COOKIE_SECURE'] = not app.debug # HTTPS 전용 (로컬 개발 시 제외) app.config['SESSION_COOKIE_SAMESITE'] = 'Lax' # QR 스캔 시 쿠키 전송 허용 app.config['PERMANENT_SESSION_LIFETIME'] = timedelta(days=90) # 3개월 유지 # ───────────────────────────────────────────────────────────── # Blueprint 등록 # ───────────────────────────────────────────────────────────── from pmr_api import pmr_bp app.register_blueprint(pmr_bp) from paai_feedback import paai_feedback_bp app.register_blueprint(paai_feedback_bp) from geoyoung_api import geoyoung_bp app.register_blueprint(geoyoung_bp) from sooin_api import sooin_bp app.register_blueprint(sooin_bp) from baekje_api import baekje_bp app.register_blueprint(baekje_bp) from dongwon_api import dongwon_bp app.register_blueprint(dongwon_bp) from wholesaler_config_api import wholesaler_config_bp app.register_blueprint(wholesaler_config_bp) from order_api import order_bp app.register_blueprint(order_bp) # 데이터베이스 매니저 db_manager = DatabaseManager() # KST 타임존 (UTC+9) KST = timezone(timedelta(hours=9)) # 키오스크 현재 세션 (메모리 변수, 서버 재시작 시 초기화) kiosk_current_session = None def utc_to_kst_str(utc_time_str): """ UTC 시간 문자열을 KST 시간 문자열로 변환 Args: utc_time_str (str): UTC 시간 문자열 (ISO 8601 형식) Returns: str: KST 시간 문자열 (YYYY-MM-DD HH:MM:SS) """ if not utc_time_str: return None try: # ISO 8601 형식 파싱 ('2026-01-23T12:28:36' 또는 '2026-01-23 12:28:36') utc_time_str = utc_time_str.replace(' ', 'T') # 공백을 T로 변환 # datetime 객체로 변환 if 'T' in utc_time_str: utc_time = datetime.fromisoformat(utc_time_str) else: utc_time = datetime.fromisoformat(utc_time_str) # UTC 타임존 설정 (naive datetime인 경우) if utc_time.tzinfo is None: utc_time = utc_time.replace(tzinfo=timezone.utc) # KST로 변환 kst_time = utc_time.astimezone(KST) # 문자열로 반환 (초 단위까지만) return kst_time.strftime('%Y-%m-%d %H:%M:%S') except Exception as e: logging.error(f"시간 변환 실패: {utc_time_str}, 오류: {e}") return utc_time_str # 변환 실패 시 원본 반환 # ===== OpenAI 설정 및 헬퍼 함수 ===== # OpenAI API 설정 OPENAI_API_KEY = os.getenv('OPENAI_API_KEY') OPENAI_MODEL = os.getenv('OPENAI_MODEL', 'gpt-4o-mini') OPENAI_MAX_TOKENS = int(os.getenv('OPENAI_MAX_TOKENS', '1000')) OPENAI_TEMPERATURE = float(os.getenv('OPENAI_TEMPERATURE', '0.7')) # OpenAI 사용 가능 여부 및 API 키 확인 if OPENAI_AVAILABLE and not OPENAI_API_KEY: logging.warning("OPENAI_API_KEY가 설정되지 않았습니다. AI 분석 기능을 사용할 수 없습니다.") OPENAI_AVAILABLE = False # System Prompt SYSTEM_PROMPT = """당신은 약국 고객 관리 전문가입니다. 고객의 구매 데이터를 분석하여 다음을 제공합니다: 1. 구매 패턴 및 행동 분석 2. 주요 관심 제품 카테고리 파악 3. 업셀링을 위한 제품 추천 4. 고객 맞춤형 마케팅 전략 응답은 반드시 JSON 형식으로 작성하며, 한국어로 작성합니다. 약국 운영자가 실제로 활용할 수 있는 구체적이고 실용적인 인사이트를 제공해야 합니다.""" # 에러 메시지 ERROR_MESSAGES = { 'NO_USER': '사용자를 찾을 수 없습니다.', 'NO_PURCHASES': '구매 이력이 없어 분석할 수 없습니다. 최소 1건 이상의 구매가 필요합니다.', 'OPENAI_NOT_AVAILABLE': 'AI 분석 기능을 사용할 수 없습니다. 관리자에게 문의하세요.', 'OPENAI_API_KEY_MISSING': 'OpenAI API 키가 설정되지 않았습니다.', 'OPENAI_API_ERROR': 'OpenAI API 호출에 실패했습니다. 잠시 후 다시 시도해주세요.', 'OPENAI_RATE_LIMIT': 'API 호출 횟수 제한에 도달했습니다. 잠시 후 다시 시도해주세요.', 'OPENAI_TIMEOUT': 'AI 분석 시간이 초과되었습니다. 다시 시도해주세요.', 'PARSING_ERROR': 'AI 응답을 처리하는 중 오류가 발생했습니다.', 'UNKNOWN_ERROR': '알 수 없는 오류가 발생했습니다. 관리자에게 문의하세요.' } def categorize_product(product_name): """제품명에서 카테고리 추정 (간단한 키워드 매칭)""" categories = { '소화제': ['타센', '베아제', '겔포스', '소화'], '진통제': ['타이레놀', '게보린', '펜잘', '이부프로펜'], '감기약': ['판콜', '화이투벤', '지르텍', '감기'], '피부약': ['후시딘', '마데카솔', '더마틱스'], '비타민': ['비타민', '센트룸', '활성비타민'], '안약': ['안약', '인공눈물'], '소염진통제': ['자미슬', '펠루비', '게보린'] } for category, keywords in categories.items(): for keyword in keywords: if keyword in product_name: return category return '기타' def prepare_analysis_prompt(user, purchases): """OpenAI API 전송용 프롬프트 생성""" # 사용자 정보 요약 user_summary = f"""사용자: {user['nickname']} ({user['phone']}) 가입일: {utc_to_kst_str(user['created_at']) if user['created_at'] else '-'} 포인트 잔액: {user['mileage_balance']:,}P 총 구매 건수: {len(purchases)}건 """ # 구매 이력 상세 purchase_details = [] total_spent = 0 all_products = [] product_freq = {} for idx, purchase in enumerate(purchases, 1): total_spent += purchase['amount'] products_str = ', '.join([f"{item['name']} x{item['qty']}" for item in purchase['items']]) # 제품 빈도 계산 for item in purchase['items']: product_name = item['name'] all_products.append(product_name) product_freq[product_name] = product_freq.get(product_name, 0) + 1 purchase_details.append( f"{idx}. {purchase['date']} - {purchase['amount']:,}원 구매, {purchase['points']}P 적립\n" f" 구매 품목: {products_str}" ) # 통계 계산 avg_purchase = total_spent // len(purchases) if purchases else 0 top_products = sorted(product_freq.items(), key=lambda x: x[1], reverse=True)[:5] top_products_str = ', '.join([f"{name}({count}회)" for name, count in top_products]) # 최종 프롬프트 조립 prompt = f"""다음은 약국 고객의 구매 데이터입니다. 구매 패턴을 분석하고 마케팅 전략을 제안해주세요. {user_summary} 통계 요약: - 총 구매 금액: {total_spent:,}원 - 평균 구매 금액: {avg_purchase:,}원 - 자주 구매한 품목: {top_products_str} 구매 이력 (최근 {len(purchases)}건): {chr(10).join(purchase_details)} 분석 요청사항: 1. 구매 패턴 분석: 구매 빈도, 구매 금액 패턴 등 2. 주로 구매하는 품목 카테고리 (예: 소화제, 감기약, 건강기능식품 등) 3. 추천 제품: 기존 구매 패턴을 기반으로 관심있을만한 제품 3-5가지 (업셀링) 4. 마케팅 전략: 이 고객에게 효과적일 프로모션 또는 포인트 활용 방안 응답은 다음 JSON 형식으로 해주세요: {{ "pattern": "구매 패턴에 대한 상세한 분석 (2-3문장)", "main_products": ["카테고리1: 품목들", "카테고리2: 품목들"], "recommendations": ["추천제품1 (이유)", "추천제품2 (이유)", "추천제품3 (이유)"], "marketing_strategy": "마케팅 전략 제안 (2-3문장)" }} """ return prompt def parse_openai_response(response_text): """OpenAI API 응답을 파싱하여 구조화된 데이터 반환""" import re try: # JSON 추출 (마크다운 코드 블록 제거) json_match = re.search(r'```json\s*(\{.*?\})\s*```', response_text, re.DOTALL) if json_match: json_str = json_match.group(1) else: # 코드 블록 없이 JSON만 있는 경우 json_str = response_text.strip() # JSON 파싱 analysis = json.loads(json_str) # 필수 필드 검증 required_fields = ['pattern', 'main_products', 'recommendations', 'marketing_strategy'] for field in required_fields: if field not in analysis: raise ValueError(f"필수 필드 누락: {field}") # 타입 검증 if not isinstance(analysis['main_products'], list): analysis['main_products'] = [str(analysis['main_products'])] if not isinstance(analysis['recommendations'], list): analysis['recommendations'] = [str(analysis['recommendations'])] return analysis except json.JSONDecodeError as e: # JSON 파싱 실패 시 fallback logging.error(f"JSON 파싱 실패: {e}") return { 'pattern': '응답 파싱에 실패했습니다.', 'main_products': ['분석 결과를 확인할 수 없습니다.'], 'recommendations': ['다시 시도해주세요.'], 'marketing_strategy': response_text[:500] } except Exception as e: logging.error(f"응답 파싱 오류: {e}") raise def handle_openai_error(error): """OpenAI API 에러를 사용자 친화적 메시지로 변환""" error_str = str(error).lower() if 'api key' in error_str or 'authentication' in error_str: return ERROR_MESSAGES['OPENAI_API_KEY_MISSING'] elif 'rate limit' in error_str or 'quota' in error_str: return ERROR_MESSAGES['OPENAI_RATE_LIMIT'] elif 'timeout' in error_str: return ERROR_MESSAGES['OPENAI_TIMEOUT'] else: return ERROR_MESSAGES['OPENAI_API_ERROR'] def call_openai_with_retry(prompt, max_retries=3): """재시도 로직을 포함한 OpenAI API 호출""" if not OPENAI_AVAILABLE: return False, ERROR_MESSAGES['OPENAI_NOT_AVAILABLE'] client = OpenAI(api_key=OPENAI_API_KEY) for attempt in range(max_retries): try: response = client.chat.completions.create( model=OPENAI_MODEL, messages=[ {"role": "system", "content": SYSTEM_PROMPT}, {"role": "user", "content": prompt} ], max_tokens=OPENAI_MAX_TOKENS, temperature=OPENAI_TEMPERATURE, timeout=30 ) return True, response except RateLimitError as e: if attempt < max_retries - 1: wait_time = 2 ** attempt logging.warning(f"OpenAI Rate limit, {wait_time}초 대기 후 재시도...") time.sleep(wait_time) else: return False, handle_openai_error(e) except APITimeoutError as e: if attempt < max_retries - 1: logging.warning(f"OpenAI 타임아웃, 재시도 중... ({attempt+1}/{max_retries})") time.sleep(1) else: return False, ERROR_MESSAGES['OPENAI_TIMEOUT'] except OpenAIError as e: return False, handle_openai_error(e) except Exception as e: logging.error(f"OpenAI API 호출 오류: {e}") return False, ERROR_MESSAGES['UNKNOWN_ERROR'] return False, ERROR_MESSAGES['OPENAI_TIMEOUT'] def verify_claim_token(transaction_id, nonce): """ QR 토큰 검증 Args: transaction_id (str): 거래 ID nonce (str): 12자 hex nonce Returns: tuple: (성공 여부, 메시지, 토큰 정보 dict) """ try: conn = db_manager.get_sqlite_connection() cursor = conn.cursor() # 1. 거래 ID로 토큰 조회 cursor.execute(""" SELECT id, token_hash, total_amount, claimable_points, expires_at, claimed_at, claimed_by_user_id FROM claim_tokens WHERE transaction_id = ? """, (transaction_id,)) token_record = cursor.fetchone() if not token_record: return (False, "유효하지 않은 QR 코드입니다.", None) # 2. 이미 적립된 토큰인지 확인 if token_record['claimed_at']: return (False, "이미 적립 완료된 영수증입니다.", None) # 3. 만료 확인 expires_at = datetime.strptime(token_record['expires_at'], '%Y-%m-%d %H:%M:%S') if datetime.now() > expires_at: return (False, "적립 기간이 만료되었습니다 (30일).", None) # 4. 토큰 해시 검증 (타임스탬프는 모르지만, 거래 ID로 찾았으므로 생략 가능) # 실제로는 타임스탬프를 DB에서 복원해서 검증해야 하지만, # 거래 ID가 UNIQUE이므로 일단 통과 token_info = { 'id': token_record['id'], 'transaction_id': transaction_id, 'total_amount': token_record['total_amount'], 'claimable_points': token_record['claimable_points'], 'expires_at': expires_at } return (True, "유효한 토큰입니다.", token_info) except Exception as e: return (False, f"토큰 검증 실패: {str(e)}", None) def get_or_create_user(phone, name, birthday=None): """ 사용자 조회 또는 생성 (간편 적립용) Args: phone (str): 전화번호 name (str): 이름 birthday (str, optional): 생년월일 (YYYY-MM-DD) Returns: tuple: (user_id, is_new_user) """ conn = db_manager.get_sqlite_connection() cursor = conn.cursor() # 전화번호로 조회 cursor.execute(""" SELECT id, mileage_balance FROM users WHERE phone = ? """, (phone,)) user = cursor.fetchone() if user: # 기존 유저: birthday가 제공되면 업데이트 if birthday: cursor.execute("UPDATE users SET birthday = ? WHERE id = ?", (birthday, user['id'])) conn.commit() return (user['id'], False) # 신규 생성 cursor.execute(""" INSERT INTO users (nickname, phone, birthday, mileage_balance) VALUES (?, ?, ?, 0) """, (name, phone, birthday)) conn.commit() return (cursor.lastrowid, True) def claim_mileage(user_id, token_info): """ 마일리지 적립 처리 Args: user_id (int): 사용자 ID token_info (dict): 토큰 정보 Returns: tuple: (성공 여부, 메시지, 적립 후 잔액) """ try: conn = db_manager.get_sqlite_connection() cursor = conn.cursor() # 1. 현재 잔액 조회 cursor.execute("SELECT mileage_balance FROM users WHERE id = ?", (user_id,)) user = cursor.fetchone() current_balance = user['mileage_balance'] # 2. 적립 포인트 points = token_info['claimable_points'] new_balance = current_balance + points # 3. 사용자 잔액 업데이트 cursor.execute(""" UPDATE users SET mileage_balance = ?, updated_at = ? WHERE id = ? """, (new_balance, datetime.now().strftime('%Y-%m-%d %H:%M:%S'), user_id)) # 4. 마일리지 원장 기록 cursor.execute(""" INSERT INTO mileage_ledger (user_id, transaction_id, points, balance_after, reason, description) VALUES (?, ?, ?, ?, ?, ?) """, ( user_id, token_info['transaction_id'], points, new_balance, 'CLAIM', f"영수증 QR 적립 ({token_info['total_amount']:,}원 구매)" )) # 5. claim_tokens 업데이트 (적립 완료 표시) cursor.execute(""" UPDATE claim_tokens SET claimed_at = ?, claimed_by_user_id = ? WHERE id = ? """, (datetime.now().strftime('%Y-%m-%d %H:%M:%S'), user_id, token_info['id'])) conn.commit() return (True, f"{points}P 적립 완료!", new_balance) except Exception as e: conn.rollback() return (False, f"적립 처리 실패: {str(e)}", 0) def normalize_kakao_phone(kakao_phone): """ 카카오 전화번호 형식 변환 "+82 10-1234-5678" → "01012345678" """ if not kakao_phone: return None digits = kakao_phone.replace('+', '').replace('-', '').replace(' ', '') if digits.startswith('82'): digits = '0' + digits[2:] if len(digits) >= 10: return digits return None def link_kakao_identity(user_id, kakao_id, kakao_data, token_data=None): """카카오 계정을 customer_identities에 연결 (토큰 포함)""" conn = db_manager.get_sqlite_connection() cursor = conn.cursor() cursor.execute(""" SELECT id FROM customer_identities WHERE provider = 'kakao' AND provider_user_id = ? """, (kakao_id,)) is_new_link = cursor.fetchone() is None store_data = {k: v for k, v in kakao_data.items() if k != 'raw_data'} if is_new_link: cursor.execute(""" INSERT INTO customer_identities (user_id, provider, provider_user_id, provider_data, access_token, refresh_token, token_expires_at) VALUES (?, 'kakao', ?, ?, ?, ?, ?) """, ( user_id, kakao_id, json.dumps(store_data, ensure_ascii=False), token_data.get('access_token') if token_data else None, token_data.get('refresh_token') if token_data else None, token_data.get('expires_at') if token_data else None, )) elif token_data: # 기존 레코드: 토큰 + 프로필 데이터 업데이트 cursor.execute(""" UPDATE customer_identities SET access_token = ?, refresh_token = ?, token_expires_at = ?, provider_data = ? WHERE provider = 'kakao' AND provider_user_id = ? """, ( token_data.get('access_token'), token_data.get('refresh_token'), token_data.get('expires_at'), json.dumps(store_data, ensure_ascii=False), kakao_id, )) # 프로필 이미지, 이메일 업데이트 updates = [] params = [] if kakao_data.get('profile_image'): updates.append("profile_image_url = ?") params.append(kakao_data['profile_image']) if kakao_data.get('email'): updates.append("email = ?") params.append(kakao_data['email']) # 카카오 인증일 기록 (최초 연동 시) if is_new_link: updates.append("kakao_verified_at = datetime('now')") if updates: params.append(user_id) cursor.execute(f"UPDATE users SET {', '.join(updates)} WHERE id = ?", params) conn.commit() def find_user_by_kakao_id(kakao_id): """카카오 ID로 기존 연결된 사용자 조회""" conn = db_manager.get_sqlite_connection() cursor = conn.cursor() cursor.execute(""" SELECT user_id FROM customer_identities WHERE provider = 'kakao' AND provider_user_id = ? """, (kakao_id,)) row = cursor.fetchone() return row['user_id'] if row else None def get_kakao_tokens(user_id): """사용자의 저장된 카카오 토큰 조회""" conn = db_manager.get_sqlite_connection() cursor = conn.cursor() cursor.execute(""" SELECT provider_user_id, access_token, refresh_token, token_expires_at FROM customer_identities WHERE provider = 'kakao' AND user_id = ? """, (user_id,)) row = cursor.fetchone() if row and row['access_token']: return { 'kakao_id': row['provider_user_id'], 'access_token': row['access_token'], 'refresh_token': row['refresh_token'], 'token_expires_at': row['token_expires_at'], } return None def update_kakao_tokens(kakao_id, token_data): """카카오 토큰 업데이트 (갱신 후 저장용)""" conn = db_manager.get_sqlite_connection() cursor = conn.cursor() updates = ["access_token = ?"] params = [token_data['access_token']] if 'expires_at' in token_data: updates.append("token_expires_at = ?") params.append(token_data['expires_at']) # refresh_token은 갱신 응답에 포함된 경우에만 업데이트 if 'refresh_token' in token_data: updates.append("refresh_token = ?") params.append(token_data['refresh_token']) params.append(kakao_id) cursor.execute(f""" UPDATE customer_identities SET {', '.join(updates)} WHERE provider = 'kakao' AND provider_user_id = ? """, params) conn.commit() # ============================================================================ # 라우트 # ============================================================================ @app.route('/') def index(): """메인 페이지""" logged_in = 'logged_in_user_id' in session return render_template('index.html', logged_in=logged_in, logged_in_name=session.get('logged_in_name', ''), logged_in_phone=session.get('logged_in_phone', '') ) @app.route('/signup') def signup(): """회원가입 페이지""" if 'logged_in_user_id' in session: return redirect(f"/my-page?phone={session.get('logged_in_phone', '')}") return render_template('signup.html') @app.route('/api/signup', methods=['POST']) def api_signup(): """회원가입 API""" try: data = request.get_json() name = data.get('name', '').strip() phone = data.get('phone', '').strip().replace('-', '').replace(' ', '') birthday = data.get('birthday', '').strip() or None # 선택 항목 if not name or not phone: return jsonify({'success': False, 'message': '이름과 전화번호를 모두 입력해주세요.'}), 400 if len(phone) < 10: return jsonify({'success': False, 'message': '올바른 전화번호를 입력해주세요.'}), 400 user_id, is_new = get_or_create_user(phone, name, birthday=birthday) # 세션에 유저 정보 저장 session.permanent = True session['logged_in_user_id'] = user_id session['logged_in_phone'] = phone session['logged_in_name'] = name return jsonify({ 'success': True, 'message': '가입이 완료되었습니다.' if is_new else '이미 가입된 회원입니다. 로그인되었습니다.', 'is_new': is_new }) except Exception as e: return jsonify({'success': False, 'message': f'오류가 발생했습니다: {str(e)}'}), 500 @app.route('/claim') def claim(): """ QR 코드 랜딩 페이지 URL: /claim?t=transaction_id:nonce """ # 토큰 파라미터 파싱 token_param = request.args.get('t', '') if ':' not in token_param: return render_template('error.html', message="잘못된 QR 코드 형식입니다.") parts = token_param.split(':') if len(parts) != 2: return render_template('error.html', message="잘못된 QR 코드 형식입니다.") transaction_id, nonce = parts[0], parts[1] # 토큰 검증 success, message, token_info = verify_claim_token(transaction_id, nonce) if not success: return render_template('error.html', message=message) # 세션에 로그인된 유저가 있으면 자동 적립 (PWA) if 'logged_in_user_id' in session: auto_user_id = session['logged_in_user_id'] conn = db_manager.get_sqlite_connection() cursor = conn.cursor() cursor.execute("SELECT id, nickname, phone, mileage_balance FROM users WHERE id = ?", (auto_user_id,)) auto_user = cursor.fetchone() if auto_user: auto_success, auto_msg, auto_balance = claim_mileage(auto_user_id, token_info) if auto_success: return render_template('claim_kakao_success.html', points=token_info['claimable_points'], balance=auto_balance, phone=auto_user['phone'], name=auto_user['nickname']) return render_template('error.html', message=auto_msg) else: # 유저가 삭제됨 - 세션 클리어 session.pop('logged_in_user_id', None) session.pop('logged_in_phone', None) session.pop('logged_in_name', None) # MSSQL에서 구매 품목 조회 sale_items = [] try: db_session = db_manager.get_session('PM_PRES') sale_sub_query = text(""" SELECT ISNULL(G.GoodsName, '(약품명 없음)') AS goods_name, S.SL_NM_item AS quantity, S.SL_TOTAL_PRICE AS total FROM SALE_SUB S LEFT JOIN PM_DRUG.dbo.CD_GOODS G ON S.DrugCode = G.DrugCode WHERE S.SL_NO_order = :transaction_id ORDER BY S.DrugCode """) rows = db_session.execute(sale_sub_query, {'transaction_id': transaction_id}).fetchall() sale_items = [ {'name': r.goods_name, 'qty': int(r.quantity or 0), 'total': int(r.total or 0)} for r in rows ] except Exception as e: logging.warning(f"품목 조회 실패 (transaction_id={transaction_id}): {e}") # JS SDK용 카카오 state 생성 (CSRF 보호) csrf_token = secrets.token_hex(16) state_data = {'t': token_param, 'csrf': csrf_token} kakao_state = base64.urlsafe_b64encode( json.dumps(state_data).encode() ).decode() session['kakao_csrf'] = csrf_token return render_template('claim_form.html', token_info=token_info, sale_items=sale_items, kakao_state=kakao_state) @app.route('/api/claim', methods=['POST']) def api_claim(): """ 마일리지 적립 API POST /api/claim Body: { "transaction_id": "...", "nonce": "...", "phone": "010-1234-5678", "name": "홍길동" } """ try: data = request.get_json() transaction_id = data.get('transaction_id') nonce = data.get('nonce') phone = data.get('phone', '').strip() name = data.get('name', '').strip() privacy_consent = data.get('privacy_consent', False) # 입력 검증 if not phone or not name: return jsonify({ 'success': False, 'message': '전화번호와 이름을 모두 입력해주세요.' }), 400 # 개인정보 동의 검증 if not privacy_consent: return jsonify({ 'success': False, 'message': '개인정보 수집·이용에 동의해주세요.' }), 400 # 전화번호 형식 정리 (하이픈 제거) phone = phone.replace('-', '').replace(' ', '') if len(phone) < 10: return jsonify({ 'success': False, 'message': '올바른 전화번호를 입력해주세요.' }), 400 # 토큰 검증 success, message, token_info = verify_claim_token(transaction_id, nonce) if not success: return jsonify({ 'success': False, 'message': message }), 400 # 사용자 조회/생성 user_id, is_new = get_or_create_user(phone, name) # 마일리지 적립 success, message, new_balance = claim_mileage(user_id, token_info) if not success: return jsonify({ 'success': False, 'message': message }), 500 # 세션에 유저 정보 저장 (PWA 자동적립용) session.permanent = True session['logged_in_user_id'] = user_id session['logged_in_phone'] = phone session['logged_in_name'] = name return jsonify({ 'success': True, 'message': message, 'points': token_info['claimable_points'], 'balance': new_balance, 'is_new_user': is_new }) except Exception as e: return jsonify({ 'success': False, 'message': f'오류가 발생했습니다: {str(e)}' }), 500 # ============================================================================ # 카카오 적립 라우트 # ============================================================================ @app.route('/claim/kakao/start') def claim_kakao_start(): """카카오 OAuth 시작 - claim 컨텍스트를 state에 담아 카카오로 리다이렉트""" from services.kakao_client import get_kakao_client token_param = request.args.get('t', '') if ':' not in token_param: return render_template('error.html', message="잘못된 QR 코드입니다.") parts = token_param.split(':') if len(parts) != 2: return render_template('error.html', message="잘못된 QR 코드입니다.") transaction_id, nonce = parts # 토큰 사전 검증 success, message, token_info = verify_claim_token(transaction_id, nonce) if not success: return render_template('error.html', message=message) # state: claim 컨텍스트 + CSRF 토큰 csrf_token = secrets.token_hex(16) state_data = { 't': token_param, 'csrf': csrf_token } state_encoded = base64.urlsafe_b64encode( json.dumps(state_data).encode() ).decode() session['kakao_csrf'] = csrf_token kakao_client = get_kakao_client() auth_url = kakao_client.get_authorization_url(state=state_encoded) return redirect(auth_url) def _handle_mypage_kakao_callback(code, kakao_client, redirect_to=None): """ 마이페이지 카카오 콜백 처리 - 카카오 연동(머지) + 마이페이지 이동 케이스별 동작: A) 카카오 ID가 이미 연결된 유저 → 그 유저의 마이페이지로 이동 B) 미연결 + 카카오 전화번호로 기존 유저 발견 → 카카오 연동 후 이동 C) 미연결 + 기존 유저 없음 → 신규 생성 + 카카오 연동 D) 전화번호 없음 → 에러 안내 Args: redirect_to: 리다이렉트할 URL (None이면 기본 /my-page?phone=xxx) """ success, token_data = kakao_client.get_access_token(code) if not success: return render_template('error.html', message="카카오 인증에 실패했습니다.") access_token = token_data.get('access_token') success, user_info = kakao_client.get_user_info(access_token) if not success: return render_template('error.html', message="카카오 사용자 정보를 가져올 수 없습니다.") kakao_id = user_info.get('kakao_id') kakao_phone_raw = user_info.get('phone_number') kakao_phone = normalize_kakao_phone(kakao_phone_raw) kakao_name = user_info.get('name') or user_info.get('nickname', '고객') conn = db_manager.get_sqlite_connection() cursor = conn.cursor() # Case A: 카카오 ID로 이미 연결된 유저 있음 existing_user_id = find_user_by_kakao_id(kakao_id) if existing_user_id: # "고객" 이름이면 카카오 실명으로 업데이트 if kakao_name and kakao_name != '고객': cursor.execute( "UPDATE users SET nickname = ? WHERE id = ? AND nickname = '고객'", (kakao_name, existing_user_id)) conn.commit() cursor.execute("SELECT phone, nickname FROM users WHERE id = ?", (existing_user_id,)) row = cursor.fetchone() if row and row['phone']: # 세션에 로그인 정보 저장 session['logged_in_user_id'] = existing_user_id session['logged_in_phone'] = row['phone'] session['logged_in_name'] = row['nickname'] or kakao_name if redirect_to: return redirect(redirect_to) return redirect(f"/my-page?phone={row['phone']}") # 전화번호 없으면 연동 불가 if not kakao_phone: return render_template('error.html', message="카카오 계정에 전화번호 정보가 없습니다. 카카오 설정에서 전화번호를 등록해주세요.") # Case B/C: 전화번호로 기존 유저 조회 cursor.execute("SELECT id, nickname FROM users WHERE phone = ?", (kakao_phone,)) phone_user = cursor.fetchone() if phone_user: # Case B: 기존 전화번호 유저 → 카카오 연동 (머지) user_id = phone_user['id'] link_kakao_identity(user_id, kakao_id, user_info, token_data) # "고객" 이름이면 카카오 실명으로 업데이트 if phone_user['nickname'] == '고객' and kakao_name and kakao_name != '고객': cursor.execute("UPDATE users SET nickname = ? WHERE id = ?", (kakao_name, user_id)) conn.commit() logging.info(f"마이페이지 카카오 머지: user_id={user_id}, kakao_id={kakao_id}") else: # Case C: 신규 유저 생성 + 카카오 연동 user_id, _ = get_or_create_user(kakao_phone, kakao_name) link_kakao_identity(user_id, kakao_id, user_info, token_data) logging.info(f"마이페이지 카카오 신규: user_id={user_id}, kakao_id={kakao_id}") # 세션에 로그인 정보 저장 (mypage_v2용) session['logged_in_user_id'] = user_id session['logged_in_phone'] = kakao_phone session['logged_in_name'] = kakao_name # 지정된 리다이렉트 URL이 있으면 그쪽으로 if redirect_to: return redirect(redirect_to) return redirect(f"/my-page?phone={kakao_phone}") @app.route('/claim/kakao/callback') def claim_kakao_callback(): """카카오 OAuth 콜백 - 토큰 교환 → 사용자 정보 → 적립 처리""" from services.kakao_client import get_kakao_client code = request.args.get('code') state_encoded = request.args.get('state') error = request.args.get('error') if error: return render_template('error.html', message="카카오 로그인이 취소되었습니다.") if not code or not state_encoded: return render_template('error.html', message="카카오 인증 정보가 올바르지 않습니다.") # 1. state 디코딩 try: state_json = base64.urlsafe_b64decode(state_encoded).decode() state_data = json.loads(state_json) except Exception: return render_template('error.html', message="인증 상태 정보가 올바르지 않습니다.") # 2. CSRF 검증 csrf_token = state_data.get('csrf') if csrf_token != session.pop('kakao_csrf', None): return render_template('error.html', message="보안 검증에 실패했습니다. 다시 시도해주세요.") # 2.5 마이페이지 조회 목적이면 별도 처리 if state_data.get('purpose') in ('mypage', 'mypage_v2'): redirect_to = '/mypage' if state_data.get('purpose') == 'mypage_v2' else None return _handle_mypage_kakao_callback(code, get_kakao_client(), redirect_to=redirect_to) # 3. claim 컨텍스트 복원 token_param = state_data.get('t', '') parts = token_param.split(':') if len(parts) != 2: return render_template('error.html', message="적립 정보가 올바르지 않습니다.") transaction_id, nonce = parts # 4. 토큰 재검증 (OAuth 중 다른 사람이 적립했을 수 있음) success, message, token_info = verify_claim_token(transaction_id, nonce) if not success: return render_template('error.html', message=message) # 5. 카카오 access_token 교환 kakao_client = get_kakao_client() success, token_data = kakao_client.get_access_token(code) if not success: logging.error(f"카카오 토큰 교환 실패: {token_data}") return render_template('error.html', message="카카오 인증에 실패했습니다. 다시 시도해주세요.") # 6. 사용자 정보 조회 access_token = token_data.get('access_token') success, user_info = kakao_client.get_user_info(access_token) if not success: logging.error(f"카카오 사용자 정보 조회 실패: {user_info}") return render_template('error.html', message="카카오 사용자 정보를 가져올 수 없습니다.") kakao_id = user_info.get('kakao_id') kakao_name = user_info.get('name') or user_info.get('nickname', '') kakao_phone_raw = user_info.get('phone_number') kakao_phone = normalize_kakao_phone(kakao_phone_raw) # 카카오에서 받은 생년월일 조합 kakao_birthday = None kakao_bday = user_info.get('birthday') # MMDD 형식 print(f"[KAKAO DEBUG] user_info keys: {list(user_info.keys())}") print(f"[KAKAO DEBUG] birthday={kakao_bday}, birthyear={user_info.get('birthyear')}") if kakao_bday and len(kakao_bday) == 4: if user_info.get('birthyear'): kakao_birthday = f"{user_info['birthyear']}-{kakao_bday[:2]}-{kakao_bday[2:]}" # YYYY-MM-DD else: kakao_birthday = f"{kakao_bday[:2]}-{kakao_bday[2:]}" # MM-DD (연도 없음) # 7. 분기: 전화번호가 있으면 자동 적립, 없으면 폰 입력 폼 if kakao_phone: # 자동 적립 existing_user_id = find_user_by_kakao_id(kakao_id) if existing_user_id: user_id = existing_user_id is_new = False # 생년월일이 있으면 업데이트 if kakao_birthday: conn = db_manager.get_sqlite_connection() conn.cursor().execute("UPDATE users SET birthday = ? WHERE id = ? AND birthday IS NULL", (kakao_birthday, user_id)) conn.commit() else: user_id, is_new = get_or_create_user(kakao_phone, kakao_name, birthday=kakao_birthday) link_kakao_identity(user_id, kakao_id, user_info, token_data) success, msg, new_balance = claim_mileage(user_id, token_info) if not success: return render_template('error.html', message=msg) # 세션에 유저 정보 저장 (PWA 자동적립용) session.permanent = True session['logged_in_user_id'] = user_id session['logged_in_phone'] = kakao_phone session['logged_in_name'] = kakao_name return render_template('claim_kakao_success.html', points=token_info['claimable_points'], balance=new_balance, phone=kakao_phone, name=kakao_name) else: # 전화번호 없음 → 세션에 카카오 정보 저장 + 폰 입력 폼 session['kakao_claim'] = { 'kakao_id': kakao_id, 'name': kakao_name, 'profile_image': user_info.get('profile_image'), 'email': user_info.get('email'), 'token_param': token_param } return render_template('claim_kakao_phone.html', token_info=token_info, kakao_name=kakao_name, kakao_profile_image=user_info.get('profile_image')) @app.route('/api/claim/kakao', methods=['POST']) def api_claim_kakao(): """카카오 적립 (전화번호 폴백) - 세션의 카카오 정보 + 폼의 전화번호로 적립""" kakao_data = session.pop('kakao_claim', None) if not kakao_data: return jsonify({ 'success': False, 'message': '카카오 인증 정보가 만료되었습니다. 다시 시도해주세요.' }), 400 data = request.get_json() phone = data.get('phone', '').strip().replace('-', '').replace(' ', '') if len(phone) < 10: session['kakao_claim'] = kakao_data return jsonify({ 'success': False, 'message': '올바른 전화번호를 입력해주세요.' }), 400 token_param = kakao_data['token_param'] parts = token_param.split(':') transaction_id, nonce = parts success, message, token_info = verify_claim_token(transaction_id, nonce) if not success: return jsonify({'success': False, 'message': message}), 400 kakao_id = kakao_data['kakao_id'] name = kakao_data['name'] existing_user_id = find_user_by_kakao_id(kakao_id) if existing_user_id: user_id = existing_user_id is_new = False else: user_id, is_new = get_or_create_user(phone, name) link_kakao_identity(user_id, kakao_id, kakao_data) success, message, new_balance = claim_mileage(user_id, token_info) if not success: return jsonify({'success': False, 'message': message}), 500 # 세션에 유저 정보 저장 (PWA 자동적립용) session.permanent = True session['logged_in_user_id'] = user_id session['logged_in_phone'] = phone session['logged_in_name'] = name return jsonify({ 'success': True, 'message': message, 'points': token_info['claimable_points'], 'balance': new_balance, 'is_new_user': is_new }) @app.route('/my-page/kakao/start') def mypage_kakao_start(): """마이페이지 카카오 로그인 조회""" from services.kakao_client import get_kakao_client csrf_token = secrets.token_hex(16) state_data = { 'purpose': 'mypage', 'csrf': csrf_token } state_encoded = base64.urlsafe_b64encode( json.dumps(state_data).encode() ).decode() session['kakao_csrf'] = csrf_token kakao_client = get_kakao_client() auth_url = kakao_client.get_authorization_url(state=state_encoded) return redirect(auth_url) @app.route('/mypage') def mypage_v2(): """확장 마이페이지 (카카오 로그인 필수)""" user_id = session.get('logged_in_user_id') if not user_id: # 로그인 필요 - 카카오 로그인으로 리다이렉트 csrf_token = secrets.token_hex(16) state_data = {'purpose': 'mypage_v2', 'csrf': csrf_token} kakao_state = base64.urlsafe_b64encode( json.dumps(state_data).encode() ).decode() session['kakao_csrf'] = csrf_token return render_template('my_page_login.html', kakao_state=kakao_state, redirect_to='/mypage') # 사용자 정보 조회 conn = db_manager.get_sqlite_connection() cursor = conn.cursor() cursor.execute(""" SELECT id, nickname, phone, profile_image_url, mileage_balance, created_at FROM users WHERE id = ? """, (user_id,)) user_raw = cursor.fetchone() if not user_raw: session.pop('logged_in_user_id', None) return redirect('/mypage') user = dict(user_raw) # 반려동물 목록 조회 cursor.execute(""" SELECT id, name, species, breed, gender, photo_url, created_at FROM pets WHERE user_id = ? AND is_active = TRUE ORDER BY created_at DESC """, (user_id,)) pets = [] for row in cursor.fetchall(): species_label = '강아지 🐕' if row['species'] == 'dog' else ('고양이 🐈' if row['species'] == 'cat' else '기타') pets.append({ 'id': row['id'], 'name': row['name'], 'species': row['species'], 'species_label': species_label, 'breed': row['breed'], 'gender': row['gender'], 'photo_url': row['photo_url'] }) # 구매 횟수 (적립 내역 수) cursor.execute("SELECT COUNT(*) FROM mileage_ledger WHERE user_id = ?", (user_id,)) purchase_count = cursor.fetchone()[0] return render_template('mypage_v2.html', user=user, pets=pets, purchase_count=purchase_count) @app.route('/my-page') def my_page(): """마이페이지 (전화번호로 조회)""" phone = request.args.get('phone', '') if not phone: # JS SDK용 카카오 state 생성 csrf_token = secrets.token_hex(16) state_data = {'purpose': 'mypage', 'csrf': csrf_token} kakao_state = base64.urlsafe_b64encode( json.dumps(state_data).encode() ).decode() session['kakao_csrf'] = csrf_token return render_template('my_page_login.html', kakao_state=kakao_state) # 전화번호로 사용자 조회 phone = phone.replace('-', '').replace(' ', '') conn = db_manager.get_sqlite_connection() cursor = conn.cursor() cursor.execute(""" SELECT id, nickname, phone, mileage_balance, created_at FROM users WHERE phone = ? """, (phone,)) user_raw = cursor.fetchone() if not user_raw: return render_template('error.html', message='등록되지 않은 전화번호입니다.') # 사용자 정보에 KST 시간 변환 적용 user = dict(user_raw) user['created_at'] = utc_to_kst_str(user_raw['created_at']) # 적립 내역 조회 (transaction_id 포함) cursor.execute(""" SELECT points, balance_after, reason, description, created_at, transaction_id FROM mileage_ledger WHERE user_id = ? ORDER BY created_at DESC LIMIT 20 """, (user['id'],)) transactions_raw = cursor.fetchall() # 거래 내역에 KST 시간 변환 적용 transactions = [] for tx in transactions_raw: tx_dict = dict(tx) tx_dict['created_at'] = utc_to_kst_str(tx['created_at']) transactions.append(tx_dict) return render_template('my_page.html', user=user, transactions=transactions, user_id=user['id']) # ============================================================================ # PWA / 공통 라우트 # ============================================================================ @app.route('/sw.js') def service_worker(): """서비스 워커를 루트 경로에서 제공 (scope='/' 허용)""" return app.send_static_file('sw.js'), 200, { 'Content-Type': 'application/javascript', 'Service-Worker-Allowed': '/' } @app.route('/privacy') def privacy(): """개인정보 처리방침""" return render_template('privacy.html') @app.route('/logout') def logout(): """세션 로그아웃""" session.pop('logged_in_user_id', None) session.pop('logged_in_phone', None) session.pop('logged_in_name', None) return redirect('/') @app.route('/admin/transaction/') def admin_transaction_detail(transaction_id): """거래 세부 내역 조회 (MSSQL)""" try: # MSSQL PM_PRES 연결 session = db_manager.get_session('PM_PRES') # SALE_MAIN 조회 (거래 헤더) sale_main_query = text(""" SELECT SL_NO_order, InsertTime, SL_MY_total, SL_MY_discount, SL_MY_sale, SL_MY_credit, SL_MY_recive, SL_MY_rec_vat, ISNULL(SL_NM_custom, '[비고객]') AS customer_name FROM SALE_MAIN WHERE SL_NO_order = :transaction_id """) sale_main = session.execute(sale_main_query, {'transaction_id': transaction_id}).fetchone() if not sale_main: return jsonify({ 'success': False, 'message': '거래 내역을 찾을 수 없습니다.' }), 404 # SALE_SUB 조회 (판매 상품 상세) sale_sub_query = text(""" SELECT S.DrugCode, ISNULL(G.GoodsName, '(약품명 없음)') AS goods_name, S.SL_NM_item AS quantity, S.SL_NM_cost_a AS price, S.SL_TOTAL_PRICE AS total FROM SALE_SUB S LEFT JOIN PM_DRUG.dbo.CD_GOODS G ON S.DrugCode = G.DrugCode WHERE S.SL_NO_order = :transaction_id ORDER BY S.DrugCode """) sale_items = session.execute(sale_sub_query, {'transaction_id': transaction_id}).fetchall() # 결과를 JSON으로 반환 result = { 'success': True, 'transaction': { 'id': sale_main.SL_NO_order, 'date': str(sale_main.InsertTime), 'total_amount': int(sale_main.SL_MY_total or 0), 'discount': int(sale_main.SL_MY_discount or 0), 'sale_amount': int(sale_main.SL_MY_sale or 0), 'credit': int(sale_main.SL_MY_credit or 0), 'supply_value': int(sale_main.SL_MY_recive or 0), 'vat': int(sale_main.SL_MY_rec_vat or 0), 'customer_name': sale_main.customer_name }, 'items': [ { 'code': item.DrugCode, 'name': item.goods_name, 'qty': int(item.quantity or 0), 'price': int(item.price or 0), 'total': int(item.total or 0) } for item in sale_items ] } return jsonify(result) except Exception as e: return jsonify({ 'success': False, 'message': f'조회 실패: {str(e)}' }), 500 @app.route('/admin/user/') def admin_user_detail(user_id): """사용자 상세 이력 조회 - 구매 이력, 적립 이력, 구매 품목""" conn = None try: # 1. SQLite 연결 (새 연결 사용) conn = db_manager.get_sqlite_connection(new_connection=True) cursor = conn.cursor() # 2. 사용자 기본 정보 조회 cursor.execute(""" SELECT id, nickname, phone, mileage_balance, created_at, birthday FROM users WHERE id = ? """, (user_id,)) user = cursor.fetchone() if not user: return jsonify({ 'success': False, 'message': '사용자를 찾을 수 없습니다.' }), 404 # 3. 마일리지 이력 조회 (최근 50건) cursor.execute(""" SELECT transaction_id, points, balance_after, reason, description, created_at FROM mileage_ledger WHERE user_id = ? ORDER BY created_at DESC LIMIT 50 """, (user_id,)) mileage_history = cursor.fetchall() # 4. 구매 이력 조회 (적립된 거래만, 최근 20건) cursor.execute(""" SELECT transaction_id, total_amount, claimable_points, claimed_at FROM claim_tokens WHERE claimed_by_user_id = ? ORDER BY claimed_at DESC LIMIT 20 """, (user_id,)) claimed_tokens = cursor.fetchall() # 5. 각 거래의 상품 상세 조회 (MSSQL) purchases = [] try: session = db_manager.get_session('PM_PRES') for token in claimed_tokens: transaction_id = token['transaction_id'] # SALE_MAIN에서 거래 시간 조회 sale_main_query = text(""" SELECT InsertTime FROM SALE_MAIN WHERE SL_NO_order = :transaction_id """) sale_main = session.execute( sale_main_query, {'transaction_id': transaction_id} ).fetchone() # 거래 시간 추출 (MSSQL의 실제 거래 시간) if sale_main and sale_main.InsertTime: transaction_date = str(sale_main.InsertTime)[:16].replace('T', ' ') else: transaction_date = '-' # SALE_SUB + CD_GOODS JOIN (BARCODE 추가) sale_items_query = text(""" SELECT S.BARCODE, S.DrugCode, ISNULL(G.GoodsName, '(약품명 없음)') AS goods_name, S.SL_NM_item AS quantity, S.SL_NM_cost_a AS price, S.SL_TOTAL_PRICE AS total FROM SALE_SUB S LEFT JOIN PM_DRUG.dbo.CD_GOODS G ON S.DrugCode = G.DrugCode WHERE S.SL_NO_order = :transaction_id ORDER BY S.DrugCode """) items_raw = session.execute( sale_items_query, {'transaction_id': transaction_id} ).fetchall() # 상품 리스트 변환 (카테고리 포함) items = [] for item in items_raw: barcode = item.BARCODE # SQLite에서 제품 카테고리 조회 (테이블 없으면 건너뜀) categories = [] if barcode: try: cursor.execute(""" SELECT category_name, relevance_score FROM product_category_mapping WHERE barcode = ? ORDER BY relevance_score DESC """, (barcode,)) for cat_row in cursor.fetchall(): categories.append({ 'name': cat_row[0], 'score': cat_row[1] }) except Exception: pass # 테이블 없으면 무시 items.append({ 'code': item.DrugCode, 'barcode': barcode, 'name': item.goods_name, 'qty': int(item.quantity or 0), 'price': int(item.price or 0), 'total': int(item.total or 0), 'categories': categories }) # 상품 요약 생성 ("첫번째상품명 외 N개") if items: first_item_name = items[0]['name'] items_count = len(items) if items_count == 1: items_summary = first_item_name else: items_summary = f"{first_item_name} 외 {items_count - 1}개" else: items_summary = "상품 정보 없음" items_count = 0 purchases.append({ 'transaction_id': transaction_id, 'date': transaction_date, # MSSQL의 실제 거래 시간 사용 'amount': int(token['total_amount']), 'points': int(token['claimable_points']), 'items_summary': items_summary, 'items_count': items_count, 'items': items }) except Exception as mssql_error: # MSSQL 연결 실패 시 빈 배열 반환 print(f"[WARNING] MSSQL 조회 실패 (user {user_id}): {mssql_error}") purchases = [] # 6. 조제 이력 조회 (전화번호 → CUSCODE → PS_main) prescriptions = [] pos_customer = None if user['phone']: try: phone_clean = user['phone'].replace('-', '').replace(' ', '') base_session = db_manager.get_session('PM_BASE') pres_session = db_manager.get_session('PM_PRES') # 전화번호로 CUSCODE 조회 (특이사항 CUSETC 포함) cuscode_query = text(""" SELECT TOP 1 CUSCODE, PANAME, CUSETC 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_clean}).fetchone() if cus_row: cuscode = cus_row.CUSCODE pos_customer = { 'cuscode': cuscode, 'name': cus_row.PANAME, 'cusetc': cus_row.CUSETC or '' # 특이(참고)사항 } # 조제 이력 조회 rx_query = text(""" SELECT TOP 30 P.PreSerial, P.Indate, P.Paname, P.Drname, P.OrderName, P.TDAYS FROM PS_main P WHERE P.CusCode = :cuscode ORDER BY P.Indate DESC, P.PreSerial DESC """) rxs = pres_session.execute(rx_query, {'cuscode': cuscode}).fetchall() for rx in rxs: # 처방 품목 조회 items_query = text(""" SELECT S.DrugCode, G.GoodsName, S.Days, S.QUAN, S.QUAN_TIME FROM PS_sub_pharm S LEFT JOIN PM_DRUG.dbo.CD_GOODS G ON S.DrugCode = G.DrugCode WHERE S.PreSerial = :pre_serial """) items = pres_session.execute(items_query, {'pre_serial': rx.PreSerial}).fetchall() prescriptions.append({ 'pre_serial': rx.PreSerial, 'date': rx.Indate, 'patient_name': rx.Paname, 'doctor': rx.Drname, 'hospital': rx.OrderName, 'total_days': rx.TDAYS, 'items': [{ 'drug_code': item.DrugCode, 'name': item.GoodsName or '알 수 없음', 'days': float(item.Days) if item.Days else 0, 'quantity': float(item.QUAN) if item.QUAN else 0, 'times_per_day': float(item.QUAN_TIME) if item.QUAN_TIME else 0 } for item in items] }) except Exception as rx_error: logging.warning(f"조제 이력 조회 실패 (user {user_id}): {rx_error}") # 7. 관심상품 조회 (AI 추천에서 '관심있어요' 누른 상품) interests = [] try: cursor.execute(""" SELECT recommended_product, recommendation_reason, trigger_products, created_at FROM ai_recommendations WHERE user_id = ? AND status = 'interested' ORDER BY created_at DESC LIMIT 20 """, (user_id,)) for row in cursor.fetchall(): interests.append({ 'product': row['recommended_product'], 'reason': row['recommendation_reason'], 'trigger_products': row['trigger_products'], 'created_at': utc_to_kst_str(row['created_at']) }) except Exception as interest_error: logging.warning(f"관심상품 조회 실패 (user {user_id}): {interest_error}") # 8. 반려동물 조회 pets = [] try: cursor.execute(""" SELECT id, name, species, breed, gender, birth_date, age_months, weight, photo_url, notes, created_at FROM pets WHERE user_id = ? AND is_active = TRUE ORDER BY created_at DESC """, (user_id,)) for row in cursor.fetchall(): species_label = '강아지 🐕' if row['species'] == 'dog' else ('고양이 🐈' if row['species'] == 'cat' else '기타') gender_label = '♂ 남아' if row['gender'] == 'male' else ('♀ 여아' if row['gender'] == 'female' else '') pets.append({ 'id': row['id'], 'name': row['name'], 'species': row['species'], 'species_label': species_label, 'breed': row['breed'], 'gender': row['gender'], 'gender_label': gender_label, 'birth_date': row['birth_date'], 'age_months': row['age_months'], 'weight': float(row['weight']) if row['weight'] else None, 'photo_url': row['photo_url'], 'notes': row['notes'], 'created_at': utc_to_kst_str(row['created_at']) }) except Exception as pet_error: logging.warning(f"반려동물 조회 실패 (user {user_id}): {pet_error}") # 9. 응답 생성 return jsonify({ 'success': True, 'user': { 'id': user['id'], 'name': user['nickname'], 'phone': user['phone'], 'balance': user['mileage_balance'], 'created_at': utc_to_kst_str(user['created_at']), 'is_kakao_verified': user['nickname'] != '고객', # 카카오 인증 여부 'birthday': user['birthday'] if user['birthday'] else None }, 'mileage_history': [ { 'points': ml['points'], 'balance_after': ml['balance_after'], 'reason': ml['reason'], 'description': ml['description'], 'created_at': utc_to_kst_str(ml['created_at']), 'transaction_id': ml['transaction_id'] } for ml in mileage_history ], 'purchases': purchases, 'prescriptions': prescriptions, 'pos_customer': pos_customer, 'interests': interests, 'pets': pets }) except Exception as e: import traceback logging.error(f"사용자 상세 조회 실패: {e}\n{traceback.format_exc()}") return jsonify({ 'success': False, 'message': f'조회 실패: {str(e)}' }), 500 finally: if conn: try: conn.close() except: pass @app.route('/admin/search/user') def admin_search_user(): """사용자 검색 (이름/전화번호/전화번호 뒷자리)""" query = request.args.get('q', '').strip() search_type = request.args.get('type', 'name') # 'name', 'phone', 'phone_last' if not query: return jsonify({'success': False, 'message': '검색어를 입력하세요'}), 400 conn = None try: conn = db_manager.get_sqlite_connection(new_connection=True) cursor = conn.cursor() if search_type == 'phone_last': # 전화번호 뒷자리 검색 cursor.execute(""" SELECT id, nickname, phone, mileage_balance FROM users WHERE phone LIKE ? ORDER BY created_at DESC """, (f'%{query}',)) elif search_type == 'phone': # 전체 전화번호 검색 cursor.execute(""" SELECT id, nickname, phone, mileage_balance FROM users WHERE phone = ? """, (query,)) else: # 이름 검색 cursor.execute(""" SELECT id, nickname, phone, mileage_balance FROM users WHERE nickname LIKE ? ORDER BY created_at DESC """, (f'%{query}%',)) results = cursor.fetchall() if not results: return jsonify({'success': False, 'message': '검색 결과가 없습니다'}), 404 if len(results) == 1: # 단일 매칭 - user_id만 반환 return jsonify({ 'success': True, 'multiple': False, 'user_id': results[0]['id'] }) else: # 여러 명 매칭 - 선택 모달용 데이터 반환 users = [{ 'id': row['id'], 'name': row['nickname'], 'phone': row['phone'], 'balance': row['mileage_balance'], 'is_kakao_verified': row['nickname'] != '고객' # 카카오 인증 여부 } for row in results] return jsonify({ 'success': True, 'multiple': True, 'users': users }) except Exception as e: import traceback logging.error(f"사용자 검색 실패: {e}\n{traceback.format_exc()}") return jsonify({ 'success': False, 'message': f'검색 실패: {str(e)}' }), 500 finally: if conn: try: conn.close() except: pass @app.route('/admin/search/product') def admin_search_product(): """제품 검색 - 적립자 목록 반환 (SQLite 적립자 기준)""" query = request.args.get('q', '').strip() if not query: return jsonify({'success': False, 'message': '검색어를 입력하세요'}), 400 conn = None try: conn = db_manager.get_sqlite_connection(new_connection=True) cursor = conn.cursor() # 1. MSSQL에서 제품명으로 거래번호 찾기 session = db_manager.get_session('PM_PRES') sale_items_query = text(""" SELECT DISTINCT S.SL_NO_order, S.SL_NM_item, M.InsertTime FROM SALE_SUB S LEFT JOIN PM_DRUG.dbo.CD_GOODS G ON S.DrugCode = G.DrugCode LEFT JOIN SALE_MAIN M ON S.SL_NO_order = M.SL_NO_order WHERE G.GoodsName LIKE :product_name ORDER BY M.InsertTime DESC """) sale_results = session.execute(sale_items_query, { 'product_name': f'%{query}%' }).fetchall() if not sale_results: return jsonify({ 'success': True, 'results': [] }) # 2. SQLite에서 적립된 거래만 필터링 (claimed_by_user_id IS NOT NULL) transaction_ids = [row.SL_NO_order for row in sale_results] placeholders = ','.join('?' * len(transaction_ids)) cursor.execute(f""" SELECT ct.transaction_id, ct.total_amount, ct.claimed_at, ct.claimed_by_user_id, u.nickname, u.phone FROM claim_tokens ct JOIN users u ON ct.claimed_by_user_id = u.id WHERE ct.transaction_id IN ({placeholders}) AND ct.claimed_by_user_id IS NOT NULL ORDER BY ct.claimed_at DESC LIMIT 50 """, transaction_ids) claimed_results = cursor.fetchall() # 3. 결과 조합 results = [] for claim_row in claimed_results: # 해당 거래의 MSSQL 정보 찾기 mssql_row = next((r for r in sale_results if r.SL_NO_order == claim_row['transaction_id']), None) if mssql_row: results.append({ 'user_id': claim_row['claimed_by_user_id'], 'user_name': claim_row['nickname'], 'user_phone': claim_row['phone'], 'purchase_date': str(mssql_row.InsertTime)[:16].replace('T', ' ') if mssql_row.InsertTime else '-', # MSSQL 실제 거래 시간 'claimed_date': str(claim_row['claimed_at'])[:16].replace('T', ' ') if claim_row['claimed_at'] else '-', # 적립 시간 'quantity': float(mssql_row.SL_NM_item or 0), 'total_amount': int(claim_row['total_amount']) }) return jsonify({ 'success': True, 'results': results }) except Exception as e: import traceback logging.error(f"제품 검색 실패: {e}\n{traceback.format_exc()}") return jsonify({ 'success': False, 'message': f'검색 실패: {str(e)}' }), 500 finally: if conn: try: conn.close() except: pass @app.route('/admin/ai-analyze-user/', methods=['POST']) def admin_ai_analyze_user(user_id): """OpenAI GPT를 사용한 사용자 구매 패턴 AI 분석""" try: # OpenAI 사용 가능 여부 확인 if not OPENAI_AVAILABLE: return jsonify({ 'success': False, 'message': ERROR_MESSAGES['OPENAI_NOT_AVAILABLE'] }), 503 # 1. SQLite 연결 conn = db_manager.get_sqlite_connection() cursor = conn.cursor() # 2. 사용자 기본 정보 조회 cursor.execute(""" SELECT id, nickname, phone, mileage_balance, created_at FROM users WHERE id = ? """, (user_id,)) user_row = cursor.fetchone() if not user_row: return jsonify({ 'success': False, 'message': ERROR_MESSAGES['NO_USER'] }), 404 user = { 'id': user_row['id'], 'nickname': user_row['nickname'], 'phone': user_row['phone'], 'mileage_balance': user_row['mileage_balance'], 'created_at': user_row['created_at'] } # 3. 구매 이력 조회 (최근 20건) cursor.execute(""" SELECT transaction_id, total_amount, claimable_points, claimed_at FROM claim_tokens WHERE claimed_by_user_id = ? ORDER BY claimed_at DESC LIMIT 20 """, (user_id,)) claimed_tokens = cursor.fetchall() if not claimed_tokens: return jsonify({ 'success': False, 'message': ERROR_MESSAGES['NO_PURCHASES'] }), 400 # 4. MSSQL에서 상품 상세 조회 purchases = [] session = db_manager.get_session('PM_PRES') for token in claimed_tokens: transaction_id = token['transaction_id'] # SALE_MAIN에서 거래 시간 조회 sale_main_query = text(""" SELECT InsertTime FROM SALE_MAIN WHERE SL_NO_order = :transaction_id """) sale_main = session.execute(sale_main_query, {'transaction_id': transaction_id}).fetchone() # SALE_SUB + CD_GOODS JOIN sale_items_query = text(""" SELECT S.DrugCode, ISNULL(G.GoodsName, '(약품명 없음)') AS goods_name, S.SL_NM_item AS quantity, S.SL_NM_cost_a AS price, S.SL_TOTAL_PRICE AS total FROM SALE_SUB S LEFT JOIN PM_DRUG.dbo.CD_GOODS G ON S.DrugCode = G.DrugCode WHERE S.SL_NO_order = :transaction_id ORDER BY S.DrugCode """) items_raw = session.execute(sale_items_query, {'transaction_id': transaction_id}).fetchall() items = [{ 'name': item.goods_name, 'qty': int(item.quantity or 0), 'price': int(item.price or 0) } for item in items_raw] purchases.append({ 'date': str(sale_main.InsertTime)[:16].replace('T', ' ') if sale_main and sale_main.InsertTime else '-', 'amount': int(token['total_amount']), 'points': int(token['claimable_points']), 'items': items }) # 5. OpenAI API 호출용 프롬프트 생성 prompt = prepare_analysis_prompt(user, purchases) # 6. OpenAI API 호출 logging.info(f"AI 분석 시작: 사용자 ID {user_id}") success, response = call_openai_with_retry(prompt) if not success: # response에는 에러 메시지가 담겨 있음 return jsonify({ 'success': False, 'message': response }), 500 # 7. 응답 파싱 response_text = response.choices[0].message.content analysis = parse_openai_response(response_text) logging.info(f"AI 분석 완료: 사용자 ID {user_id}, 토큰: {response.usage.total_tokens}") # 8. 결과 반환 return jsonify({ 'success': True, 'user': { 'id': user['id'], 'name': user['nickname'], 'phone': user['phone'], 'balance': user['mileage_balance'] }, 'analysis': analysis, 'metadata': { 'model_used': OPENAI_MODEL, 'tokens_used': response.usage.total_tokens, 'analysis_time': datetime.now(KST).strftime('%Y-%m-%d %H:%M:%S') } }) except Exception as e: logging.error(f"AI 분석 오류: {e}") return jsonify({ 'success': False, 'message': ERROR_MESSAGES['UNKNOWN_ERROR'] }), 500 @app.route('/admin/use-points', methods=['POST']) def admin_use_points(): """관리자 페이지에서 포인트 사용 (차감)""" try: data = request.get_json() user_id = data.get('user_id') points_to_use = data.get('points') if not user_id or not points_to_use: return jsonify({ 'success': False, 'message': '사용자 ID와 포인트를 입력하세요.' }), 400 if points_to_use <= 0: return jsonify({ 'success': False, 'message': '1 이상의 포인트를 입력하세요.' }), 400 conn = db_manager.get_sqlite_connection() cursor = conn.cursor() # 1. 현재 사용자 잔액 확인 cursor.execute(""" SELECT id, nickname, mileage_balance FROM users WHERE id = ? """, (user_id,)) user = cursor.fetchone() if not user: return jsonify({ 'success': False, 'message': '사용자를 찾을 수 없습니다.' }), 404 current_balance = user['mileage_balance'] # 2. 잔액 확인 if points_to_use > current_balance: return jsonify({ 'success': False, 'message': f'잔액({current_balance:,}P)이 부족합니다.' }), 400 # 3. 포인트 차감 new_balance = current_balance - points_to_use cursor.execute(""" UPDATE users SET mileage_balance = ? WHERE id = ? """, (new_balance, user_id)) # 4. 마일리지 원장 기록 cursor.execute(""" INSERT INTO mileage_ledger (user_id, transaction_id, points, balance_after, reason, description) VALUES (?, NULL, ?, ?, 'USE', ?) """, ( user_id, -points_to_use, # 음수로 저장 new_balance, f'포인트 사용 (관리자) - {points_to_use:,}P 차감' )) conn.commit() logging.info(f"포인트 사용 완료: 사용자 ID {user_id}, 차감 {points_to_use}P, 잔액 {new_balance}P") return jsonify({ 'success': True, 'message': f'{points_to_use:,}P 사용 완료', 'new_balance': new_balance, 'used_points': points_to_use }) except Exception as e: logging.error(f"포인트 사용 실패: {e}") return jsonify({ 'success': False, 'message': f'포인트 사용 실패: {str(e)}' }), 500 @app.route('/admin') def admin(): """관리자 페이지 - 전체 사용자 및 적립 현황""" conn = db_manager.get_sqlite_connection() cursor = conn.cursor() # 전체 통계 cursor.execute(""" SELECT COUNT(*) as total_users, SUM(mileage_balance) as total_balance FROM users """) stats = cursor.fetchone() # 최근 가입 사용자 (20명) cursor.execute(""" SELECT id, nickname, phone, mileage_balance, created_at, kakao_verified_at FROM users ORDER BY created_at DESC LIMIT 20 """) recent_users_raw = cursor.fetchall() # 시간을 KST로 변환 + 조제 이력 확인 recent_users = [] # MSSQL 세션 (조제 이력 확인용) base_session = None pres_session = None try: base_session = db_manager.get_session('PM_BASE') pres_session = db_manager.get_session('PM_PRES') except: pass for user in recent_users_raw: user_dict = dict(user) user_dict['created_at'] = utc_to_kst_str(user['created_at']) user_dict['kakao_verified_at'] = utc_to_kst_str(user['kakao_verified_at']) if user['kakao_verified_at'] else None # 조제 이력 확인 (MSSQL) user_dict['has_prescription'] = False if base_session and pres_session and user['phone']: try: phone_clean = user['phone'].replace('-', '').replace(' ', '') # 1단계: PM_BASE에서 전화번호로 CUSCODE 조회 cuscode_query = text(""" SELECT TOP 1 CUSCODE 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_clean}).fetchone() if cus_row: # 2단계: PM_PRES에서 CUSCODE로 조제 기록 확인 pres_check = pres_session.execute(text(""" SELECT TOP 1 PreSerial FROM PS_main WHERE CusCode = :cuscode """), {'cuscode': cus_row.CUSCODE}).fetchone() if pres_check: user_dict['has_prescription'] = True except Exception as e: logging.warning(f"조제 이력 확인 실패 (user {user['id']}): {e}") recent_users.append(user_dict) # 최근 적립 내역 (50건) cursor.execute(""" SELECT ml.id, u.nickname, u.phone, ml.points, ml.balance_after, ml.reason, ml.description, ml.created_at, ml.transaction_id FROM mileage_ledger ml JOIN users u ON ml.user_id = u.id ORDER BY ml.created_at DESC LIMIT 50 """) recent_transactions_raw = cursor.fetchall() # 시간을 KST로 변환 recent_transactions = [] for trans in recent_transactions_raw: trans_dict = dict(trans) trans_dict['created_at'] = utc_to_kst_str(trans['created_at']) recent_transactions.append(trans_dict) # QR 토큰 통계 cursor.execute(""" SELECT COUNT(*) as total_tokens, SUM(CASE WHEN claimed_at IS NOT NULL THEN 1 ELSE 0 END) as claimed_count, SUM(CASE WHEN claimed_at IS NULL THEN 1 ELSE 0 END) as unclaimed_count, SUM(claimable_points) as total_points_issued, SUM(CASE WHEN claimed_at IS NOT NULL THEN claimable_points ELSE 0 END) as total_points_claimed FROM claim_tokens """) token_stats = cursor.fetchone() # 반려동물 통계 cursor.execute(""" SELECT COUNT(*) as total_pets, SUM(CASE WHEN species = 'dog' THEN 1 ELSE 0 END) as dog_count, SUM(CASE WHEN species = 'cat' THEN 1 ELSE 0 END) as cat_count, COUNT(DISTINCT user_id) as owners_count FROM pets WHERE is_active = 1 """) pet_stats = cursor.fetchone() # 최근 등록 반려동물 (10마리) cursor.execute(""" SELECT p.id, p.name, p.species, p.breed, p.photo_url, p.created_at, u.nickname as owner_name, u.phone as owner_phone FROM pets p JOIN users u ON p.user_id = u.id WHERE p.is_active = 1 ORDER BY p.created_at DESC LIMIT 10 """) recent_pets_raw = cursor.fetchall() recent_pets = [] for pet in recent_pets_raw: pet_dict = dict(pet) pet_dict['created_at'] = utc_to_kst_str(pet['created_at']) recent_pets.append(pet_dict) # 최근 QR 발행 내역 (20건) cursor.execute(""" SELECT transaction_id, total_amount, claimable_points, claimed_at, claimed_by_user_id, created_at FROM claim_tokens ORDER BY created_at DESC LIMIT 20 """) recent_tokens_raw = cursor.fetchall() # Convert only created_at (발행일), leave claimed_at (적립일) unconverted recent_tokens = [] for token in recent_tokens_raw: token_dict = dict(token) token_dict['created_at'] = utc_to_kst_str(token['created_at']) # Convert 발행일 # claimed_at stays as-is (or remains None if not claimed) recent_tokens.append(token_dict) return render_template('admin.html', stats=stats, recent_users=recent_users, recent_transactions=recent_transactions, token_stats=token_stats, recent_tokens=recent_tokens, pet_stats=pet_stats, recent_pets=recent_pets) # ============================================================================ # AI 업셀링 추천 # ============================================================================ def _get_available_products(): """약국 보유 제품 목록 (최근 30일 판매 실적 기준 TOP 40)""" try: mssql_session = db_manager.get_session('PM_PRES') rows = mssql_session.execute(text(""" SELECT TOP 40 ISNULL(G.GoodsName, '') AS name, COUNT(*) as sales, MAX(G.Saleprice) as price FROM SALE_SUB S LEFT JOIN PM_DRUG.dbo.CD_GOODS G ON S.DrugCode = G.DrugCode WHERE S.SL_DT_appl >= CONVERT(VARCHAR(8), DATEADD(DAY, -30, GETDATE()), 112) AND G.GoodsName IS NOT NULL AND G.GoodsName NOT LIKE N'%(판매중지)%' GROUP BY G.GoodsName ORDER BY COUNT(*) DESC """)).fetchall() return [{'name': r.name, 'price': float(r.price or 0), 'sales': r.sales} for r in rows] except Exception as e: logging.warning(f"[AI추천] 보유 제품 목록 조회 실패: {e}") return [] def _generate_upsell_recommendation(user_id, transaction_id, sale_items, user_name): """키오스크 적립 후 AI 업셀링 추천 생성 (fire-and-forget)""" from services.clawdbot_client import generate_upsell, generate_upsell_real if not sale_items: return # 현재 구매 품목 current_items = ', '.join(item['name'] for item in sale_items if item.get('name')) if not current_items: return # 최근 구매 이력 수집 recent_products = current_items # 기본값 try: conn = db_manager.get_sqlite_connection() cursor = conn.cursor() cursor.execute(""" SELECT ct.transaction_id FROM claim_tokens ct WHERE ct.claimed_by_user_id = ? AND ct.transaction_id != ? ORDER BY ct.claimed_at DESC LIMIT 5 """, (user_id, transaction_id)) recent_tokens = cursor.fetchall() if recent_tokens: all_products = [] mssql_session = db_manager.get_session('PM_PRES') for token in recent_tokens: rows = mssql_session.execute(text(""" SELECT ISNULL(G.GoodsName, '') AS goods_name FROM SALE_SUB S LEFT JOIN PM_DRUG.dbo.CD_GOODS G ON S.DrugCode = G.DrugCode WHERE S.SL_NO_order = :tid """), {'tid': token['transaction_id']}).fetchall() for r in rows: if r.goods_name: all_products.append(r.goods_name) if all_products: recent_products = ', '.join(set(all_products)) except Exception as e: logging.warning(f"[AI추천] 구매 이력 수집 실패 (현재 품목만 사용): {e}") # 실데이터 기반 추천 (보유 제품 목록 제공) available = _get_available_products() rec = None if available: logging.info(f"[AI추천] 실데이터 생성 시작: user={user_name}, items={current_items}, 보유제품={len(available)}개") rec = generate_upsell_real(user_name, current_items, recent_products, available) # 실데이터 실패 시 기존 방식 fallback if not rec: logging.info(f"[AI추천] 자유 생성 fallback: user={user_name}") rec = generate_upsell(user_name, current_items, recent_products) if not rec: logging.warning("[AI추천] 생성 실패 (AI 응답 없음)") return # SQLite에 저장 try: conn = db_manager.get_sqlite_connection() cursor = conn.cursor() expires_at = (datetime.now() + timedelta(days=7)).strftime('%Y-%m-%d %H:%M:%S') cursor.execute(""" INSERT INTO ai_recommendations (user_id, transaction_id, recommended_product, recommendation_message, recommendation_reason, trigger_products, ai_raw_response, expires_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?) """, ( user_id, transaction_id, rec['product'], rec['message'], rec['reason'], json.dumps([item['name'] for item in sale_items], ensure_ascii=False), json.dumps(rec, ensure_ascii=False), expires_at )) conn.commit() logging.info(f"[AI추천] 저장 완료: user_id={user_id}, product={rec['product']}") except Exception as e: logging.warning(f"[AI추천] DB 저장 실패: {e}") @app.route('/api/recommendation/') def api_get_recommendation(user_id): """마이페이지용 AI 추천 조회""" conn = db_manager.get_sqlite_connection() cursor = conn.cursor() now = datetime.now().strftime('%Y-%m-%d %H:%M:%S') cursor.execute(""" SELECT id, recommended_product, recommendation_message, created_at FROM ai_recommendations WHERE user_id = ? AND status = 'active' AND (expires_at IS NULL OR expires_at > ?) ORDER BY created_at DESC LIMIT 1 """, (user_id, now)) rec = cursor.fetchone() if not rec: return jsonify({'success': True, 'has_recommendation': False}) # 표시 횟수 업데이트 cursor.execute(""" UPDATE ai_recommendations SET displayed_count = displayed_count + 1, displayed_at = COALESCE(displayed_at, ?) WHERE id = ? """, (now, rec['id'])) conn.commit() # 제품 이미지 조회 (product_images DB에서 제품명으로 검색) product_image = None try: img_db_path = os.path.join(os.path.dirname(__file__), 'db', 'product_images.db') logging.info(f"[AI추천] 이미지 DB 경로: {img_db_path}") img_conn = sqlite3.connect(img_db_path) img_conn.row_factory = sqlite3.Row img_cursor = img_conn.cursor() product_name = rec['recommended_product'] logging.info(f"[AI추천] 검색할 제품명: {product_name}") # 제품명으로 이미지 검색 (LIKE 검색으로 부분 매칭) - 원본 이미지 사용 img_cursor.execute(""" SELECT image_base64 FROM product_images WHERE product_name LIKE ? AND image_base64 IS NOT NULL LIMIT 1 """, (f'%{product_name}%',)) img_row = img_cursor.fetchone() if img_row: product_image = img_row['image_base64'] logging.info(f"[AI추천] 이미지 찾음: {len(product_image)} bytes") else: logging.info(f"[AI추천] 이미지 없음 (제품: {product_name})") img_conn.close() except Exception as e: logging.warning(f"[AI추천] 제품 이미지 조회 실패: {e}") return jsonify({ 'success': True, 'has_recommendation': True, 'recommendation': { 'id': rec['id'], 'product': rec['recommended_product'], 'message': rec['recommendation_message'], 'image': product_image # base64 썸네일 이미지 (없으면 null) } }) @app.route('/api/recommendation//dismiss', methods=['POST']) def api_dismiss_recommendation(rec_id): """추천 닫기 / 관심 표시""" data = request.get_json(silent=True) or {} action = data.get('action', 'dismissed') if action not in ('dismissed', 'interested'): action = 'dismissed' conn = db_manager.get_sqlite_connection() cursor = conn.cursor() now = datetime.now().strftime('%Y-%m-%d %H:%M:%S') cursor.execute(""" UPDATE ai_recommendations SET status = ?, dismissed_at = ? WHERE id = ? """, (action, now, rec_id)) conn.commit() logging.info(f"[AI추천] id={rec_id} → {action}") return jsonify({'success': True}) # ============================================================================ # 알림톡 로그 # ============================================================================ @app.route('/admin/alimtalk') def admin_alimtalk(): """알림톡 발송 로그 + NHN 발송 내역""" conn = db_manager.get_sqlite_connection() cursor = conn.cursor() # 로컬 발송 로그 (최근 50건) cursor.execute(""" SELECT a.*, u.nickname, u.phone as user_phone FROM alimtalk_logs a LEFT JOIN users u ON a.user_id = u.id ORDER BY a.created_at DESC LIMIT 50 """) local_logs = [dict(row) for row in cursor.fetchall()] # 통계 cursor.execute(""" SELECT COUNT(*) as total, SUM(CASE WHEN success = 1 THEN 1 ELSE 0 END) as success_count, SUM(CASE WHEN success = 0 THEN 1 ELSE 0 END) as fail_count FROM alimtalk_logs """) stats = dict(cursor.fetchone()) # 오늘 통계 cursor.execute(""" SELECT COUNT(*) as today_total, SUM(CASE WHEN success = 1 THEN 1 ELSE 0 END) as today_success FROM alimtalk_logs WHERE date(created_at) = date('now') """) today = dict(cursor.fetchone()) stats.update(today) return render_template('admin_alimtalk.html', local_logs=local_logs, stats=stats) @app.route('/admin/ai-crm') def admin_ai_crm(): """AI 업셀링 CRM 대시보드""" conn = db_manager.get_sqlite_connection() cursor = conn.cursor() # 추천 목록 (최근 50건) + 사용자 정보 JOIN cursor.execute(""" SELECT r.*, u.nickname, u.phone as user_phone FROM ai_recommendations r LEFT JOIN users u ON r.user_id = u.id ORDER BY r.created_at DESC LIMIT 50 """) recs = [dict(row) for row in cursor.fetchall()] # trigger_products JSON 파싱 for rec in recs: tp = rec.get('trigger_products') if tp: try: rec['trigger_list'] = json.loads(tp) except Exception: rec['trigger_list'] = [tp] else: rec['trigger_list'] = [] # 통계 cursor.execute(""" SELECT COUNT(*) as total, SUM(CASE WHEN status = 'active' AND (expires_at IS NULL OR expires_at > datetime('now')) THEN 1 ELSE 0 END) as active_count, SUM(CASE WHEN status = 'interested' THEN 1 ELSE 0 END) as interested_count, SUM(CASE WHEN status = 'dismissed' THEN 1 ELSE 0 END) as dismissed_count, SUM(CASE WHEN displayed_count > 0 THEN 1 ELSE 0 END) as displayed_count FROM ai_recommendations """) stats = dict(cursor.fetchone()) # 오늘 생성 건수 cursor.execute(""" SELECT COUNT(*) as today_count FROM ai_recommendations WHERE date(created_at) = date('now') """) stats['today_count'] = cursor.fetchone()['today_count'] now = datetime.now().strftime('%Y-%m-%d %H:%M:%S') return render_template('admin_ai_crm.html', recs=recs, stats=stats, now=now) @app.route('/api/admin/alimtalk/nhn-history') def api_admin_alimtalk_nhn_history(): """NHN Cloud 실제 발송 내역 API""" from services.nhn_alimtalk import get_nhn_send_history date_str = request.args.get('date', datetime.now().strftime('%Y-%m-%d')) start = f"{date_str} 00:00" end = f"{date_str} 23:59" messages = get_nhn_send_history(start, end) result = [] for m in messages: result.append({ 'requestDate': m.get('requestDate', ''), 'recipientNo': m.get('recipientNo', ''), 'templateCode': m.get('templateCode', ''), 'messageStatus': m.get('messageStatus', ''), 'resultCode': m.get('resultCode', ''), 'resultMessage': m.get('resultMessage', ''), 'content': m.get('content', ''), }) return jsonify({'success': True, 'messages': result}) @app.route('/api/admin/alimtalk/test-send', methods=['POST']) def api_admin_alimtalk_test_send(): """관리자 수동 알림톡 발송 테스트""" from services.nhn_alimtalk import send_mileage_claim_alimtalk data = request.get_json() phone = data.get('phone', '').strip().replace('-', '') name = data.get('name', '테스트') if len(phone) < 10: return jsonify({'success': False, 'message': '올바른 전화번호를 입력해주세요.'}), 400 success, msg = send_mileage_claim_alimtalk( phone, name, 100, 500, items=[{'name': '테스트 발송', 'qty': 1, 'total': 1000}], trigger_source='admin_test' ) return jsonify({'success': success, 'message': msg}) # ============================================================================ # 키오스크 적립 # ============================================================================ @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: # 기존 토큰 사용 — QR URL은 새 nonce로 생성 # (verify_claim_token은 transaction_id로만 조회하므로 nonce 불일치 무관) claimable_points = token_row['claimable_points'] nonce = secrets.token_hex(6) from utils.qr_token_generator import QR_BASE_URL qr_url = f"{QR_BASE_URL}?t={transaction_id}:{nonce}" 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'] # MSSQL에서 구매 품목 조회 sale_items = [] try: mssql_session = db_manager.get_session('PM_PRES') sale_sub_query = text(""" SELECT ISNULL(G.GoodsName, '(약품명 없음)') AS goods_name, S.SL_NM_item AS quantity, S.SL_TOTAL_PRICE AS total FROM SALE_SUB S LEFT JOIN PM_DRUG.dbo.CD_GOODS G ON S.DrugCode = G.DrugCode WHERE S.SL_NO_order = :transaction_id ORDER BY S.DrugCode """) rows = mssql_session.execute(sale_sub_query, {'transaction_id': transaction_id}).fetchall() sale_items = [ {'name': r.goods_name, 'qty': int(r.quantity or 0), 'total': int(r.total or 0)} for r in rows ] except Exception as e: logging.warning(f"키오스크 품목 조회 실패 (transaction_id={transaction_id}): {e}") # 키오스크 세션 저장 kiosk_current_session = { 'transaction_id': transaction_id, 'amount': int(amount), 'points': claimable_points, 'qr_url': qr_url, 'items': sale_items, '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'), 'items': kiosk_current_session.get('items', []) }) @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'] sale_items = kiosk_current_session.get('items', []) 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 '고객' logging.warning(f"[알림톡] 발송 시도: phone={phone}, name={user_name}, points={claimed_points}, balance={new_balance}, items={sale_items}") success, msg = send_mileage_claim_alimtalk( phone, user_name, claimed_points, new_balance, items=sale_items, user_id=user_id, trigger_source='kiosk', transaction_id=transaction_id ) logging.warning(f"[알림톡] 발송 결과: success={success}, msg={msg}") except Exception as alimtalk_err: logging.warning(f"[알림톡] 발송 예외 (적립은 완료): {alimtalk_err}") # AI 업셀링 추천 생성 (별도 스레드 — 적립 응답 블로킹 방지) import threading def _bg_upsell(): try: _generate_upsell_recommendation(user_id, transaction_id, sale_items, user_name) except Exception as rec_err: logging.warning(f"[AI추천] 생성 예외 (적립은 완료): {rec_err}") threading.Thread(target=_bg_upsell, daemon=True).start() 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 # ===== AI Gateway 모니터 페이지 ===== @app.route('/admin/ai-gw') def admin_ai_gw(): """AI Gateway 모니터 페이지""" return render_template('admin_ai_gw.html') # ===== 판매 상세 조회 페이지 ===== @app.route('/admin/sales-detail') def admin_sales_detail(): """판매 상세 조회 페이지 (상품코드/바코드/표준코드 매핑)""" return render_template('admin_sales_detail.html') # ============================================================================= # 동물약 추천 챗봇 API # ============================================================================= # 동물약 지식 베이스 (RAG 컨텍스트) ANIMAL_DRUG_KNOWLEDGE = """ ## 🐕 심장사상충 예방약 (매월 투여) - **하트가드 (Heartgard)**: 이버멕틴 성분, 츄어블, 소/중/대형견용, 고기맛 - **다이로하트 (Dirohart)**: 이버멕틴 성분, 하트가드 제네릭, 경제적 - **하트캅 (Heartcap)**: 밀베마이신 옥심, 태블릿, 정밀한 용량 ## 🐕 외부기생충 (벼룩/진드기) 예방약 - **넥스가드 (NexGard)**: 아폭솔라너 성분, 츄어블, 1개월 지속, 맛있는 소고기맛 - **넥스가드 스펙트라**: 넥스가드 + 심장사상충 예방 (아폭솔라너 + 밀베마이신) - **브라벡토 (Bravecto)**: 플루랄라너 성분, 츄어블, **3개월** 지속 (12주) - **심파리카 (Simparica)**: 사롤라너 성분, 츄어블, 1개월 지속 - **심파리카 트리오**: 심파리카 + 심장사상충 + 내부기생충 예방 - **크레델리오 (Credelio)**: 로틸라너 성분, 1개월 지속, 소형 정제 ## 🐱 고양이 전용 - **브라벡토 스팟온 (고양이)**: 외부기생충, 3개월 지속, 바르는 타입 - **레볼루션 (Revolution)**: 셀라멕틴 성분, 스팟온, 심장사상충 + 벼룩 + 귀진드기 - **브로드라인**: 피프로닐 + 에피프리노미드, 내/외부기생충 + 심장사상충 ## 🪱 내부기생충 (구충제) - **파라캅 (Paracap)**: 펜벤다졸 성분, 광범위 구충 - **드론탈 (Drontal)**: 프라지콴텔 + 피란텔, 국제적 신뢰 - 기타 구충제는 위 "보유 동물약" 목록의 [대상:] 정보 참조 ## 💊 용량 안내 - 체중별 제품 선택이 중요합니다 - 소형견(5kg 미만), 중형견(5-10kg), 대형견(10-25kg), 초대형견(25kg 이상) - **각 제품의 [대상:] 정보를 확인하세요** - 개/고양이 사용 가능 여부가 표시됨 ## ⚠️ 주의사항 - 콜리/셸티/보더콜리 등 MDR1 유전자 변이견은 이버멕틴 주의 - 임신/수유 중인 동물은 수의사 상담 필요 - 체중 정확히 측정 후 제품 선택 ## 🚨 항생제 필수 경고 (퀴놀론계) - **엔로플록사신(아시엔로, Baytril)**: 🐱 고양이 망막 독성! 5mg/kg/day 초과 시 실명 위험. 대안: 마르보플록사신 - **이버멕틴 고용량**: MDR1 유전자 변이견(콜리, 셸티, 오스트레일리안 셰퍼드) 신경독성 주의 - **어린 동물 퀴놀론계**: 연골 발달에 영향, 성장기 동물 주의 """ # 동물약 챗봇 System Prompt ANIMAL_CHAT_SYSTEM_PROMPT = """당신은 약국의 동물약 전문 상담사입니다. **⚠️ 중요: 아래 "보유 동물약" 목록의 [대상:, 용량:, 금기:] 정보를 최우선으로 참조하세요!** **역할:** 동물약 추천, 용법/용량 안내, 주의사항 안내 **💊 업셀링 규칙 (중요!):** - **항생제 질문 시**: "항생제 복용 중에는 장 건강을 위해 정장제(유산균)를 함께 급여하면 좋아요!"라고 안내하고, 제스타제 같은 정장제도 함께 추천 - **구충제 질문 시**: 정기적인 예방 일정 안내 - **피부약 질문 시**: 2단계 추천 구조 적용 - 🔹 **1차 추천(치료)**: 증상에 맞는 의약품 (개시딘겔-세균성피부염, 테르비덤-진균감염 등) - 🔹 **2차 언급(보조케어)**: "염증이 가라앉은 후 회복기에는 스킨카솔 같은 피부케어 제품(의약외품)도 도움이 됩니다" - ⚠️ 스킨카솔은 **의약외품**이므로 염증 "치료"용으로 추천하지 말 것! 피부 재생/보호/보습 목적으로만 언급 - 긁힘, 작은 상처, 피부 건조, 털빠짐 예방 → 스킨카솔 단독 추천 가능 **질문 유형별 응답:** 1. **일반/정보 질문** ("~가 뭐야?", "~종류가 뭐가 있어?", "~에 대해 알려줘") - 📚 RAG 검색 결과를 활용해 **일반적인 정보** 제공 - 시중에 있는 여러 제품/성분 설명 가능 - 예: "바르는 동물약 종류" → 셀라멕틴(레볼루션, 셀라이트), 피프로닐(프론트라인, 프로닐스팟) 등 2. **추천/구매 질문** ("~추천해줘", "~사려고", "우리 약국에 있어?", "재고") - 📦 **보유 제품 목록**에서만 추천 - 재고 있는 제품 위주로 안내 - 예: "심장사상충 약 추천해줘" → 보유 중인 하트세이버, 다이로하트 추천 3. **비교/상세 질문** ("~랑 차이", "자세히", "왜", "성분", "작용기전") - 📚 RAG + 보유 목록 둘 다 활용 - 일반적 비교 설명 + 우리 약국 보유 여부 안내 - 길게 상세히 (10-15문장) **⚠️ 투여방법 구분 (필수!):** - "먹는 약", "경구", "복용" 질문 → 내복약만 추천 (정제, 츄어블, 캡슐, 시럽) - "바르는 약", "도포", "외용" 질문 → 외용약만 추천 (겔, 스팟온, 크림, 연고) - RAG 정보의 "제형", "분류", "체중/부위" 필드 확인 필수 - 외용약(겔, 도포, 환부에 직접)은 절대 "먹는 약"으로 추천하지 않음! - 보유 제품 목록의 [내복/외용] 표시 확인! **기본 규칙:** 1. 체중별 제품은 정확한 전체 이름 사용 (안텔민킹, 안텔민뽀삐 등) 2. 용량/투약 질문: 체중별 표 형식으로 정리 3. 친근하게 🐕🐱 4. **업셀링은 자연스럽게** - 강요하지 말고 "~하면 좋아요" 식으로 부드럽게 권유 {available_products} {knowledge_base}""" def _get_animal_drug_rag(apc_codes): """PostgreSQL에서 동물약 상세 정보 조회 (RAG용)""" if not apc_codes: return {} try: from sqlalchemy import create_engine pg_engine = create_engine('postgresql://admin:trajet6640@192.168.0.87:5432/apdb_master') with pg_engine.connect() as conn: # APC 코드들로 상세 정보 조회 placeholders = ','.join([f"'{apc}'" for apc in apc_codes if apc]) if not placeholders: return {} result = conn.execute(text(f""" SELECT apc, product_name, main_ingredient, component_name_ko, image_url1, llm_pharm->>'사용가능 동물' as target_animals, llm_pharm->>'분류' as category, llm_pharm->>'쉬운분류' as easy_category, llm_pharm->>'약품 제형' as dosage_form, llm_pharm->>'체중/부위' as dosage_weight, llm_pharm->>'기간/용법' as usage_period, llm_pharm->>'월령금기' as age_restriction, llm_pharm->>'반려인주의' as owner_caution, llm_pharm->>'주성분' as main_ingredient_detail, llm_pharm->>'LLM설명' as description, llm_pharm->>'어떤질병에사용하나요?' as usage_for FROM apc WHERE apc IN ({placeholders}) """)) rag_data = {} for row in result: rag_data[row.apc] = { 'target_animals': row.target_animals or '정보 없음', 'category': row.category or '', 'easy_category': row.easy_category or '', 'dosage_form': row.dosage_form or '', 'dosage_weight': row.dosage_weight or '', 'usage_period': row.usage_period or '', 'age_restriction': row.age_restriction or '', 'owner_caution': row.owner_caution or '', 'main_ingredient': row.component_name_ko or row.main_ingredient_detail or row.main_ingredient or '', 'description': row.description or '', 'usage_for': row.usage_for or '', 'image_url': row.image_url1 or '' } return rag_data except Exception as e: logging.warning(f"동물약 RAG 조회 실패: {e}") return {} def _get_animal_drugs(): """보유 중인 동물약 목록 조회 (APC 이미지 포함) APC 우선순위: 1. CD_ITEM_UNIT_MEMBER에서 APC 코드 (0xx: ~2023년, 9xx: 2024년~) 2. 없으면 기존 BARCODE를 PostgreSQL에서 조회 (바코드=APC인 경우) """ try: drug_session = db_manager.get_session('PM_DRUG') # CD_ITEM_UNIT_MEMBER에서 APC 바코드 조회 + IM_total에서 재고 조회 query = text(""" SELECT G.DrugCode, G.GoodsName, G.Saleprice, G.BARCODE, ISNULL(IT.IM_QT_sale_debit, 0) AS Stock, ( SELECT TOP 1 U.CD_CD_BARCODE FROM CD_ITEM_UNIT_MEMBER U WHERE U.DRUGCODE = G.DrugCode AND (U.CD_CD_BARCODE LIKE '02%' OR U.CD_CD_BARCODE LIKE '92%') AND LEN(U.CD_CD_BARCODE) = 13 ORDER BY U.CHANGE_DATE DESC ) AS APC_CODE FROM CD_GOODS G LEFT JOIN IM_total IT ON G.DrugCode = IT.DrugCode WHERE G.POS_BOON = '010103' AND G.GoodsSelCode = 'B' ORDER BY G.GoodsName """) rows = drug_session.execute(query).fetchall() result = [] apc_list = [] for r in rows: apc = r.APC_CODE if hasattr(r, 'APC_CODE') else None barcode = r.BARCODE or '' # APC가 없으면 바코드를 APC로 사용 (PostgreSQL에서 바코드=APC인 경우) if not apc and barcode: apc = barcode if apc: apc_list.append(apc) result.append({ 'code': r.DrugCode, 'name': r.GoodsName, 'price': float(r.Saleprice) if r.Saleprice else 0, 'barcode': barcode, 'apc': apc, 'stock': int(r.Stock) if r.Stock else 0, 'wholesaler_stock': 0, # PostgreSQL에서 가져옴 'category': None, # PostgreSQL에서 가져옴 'image_url': None # PostgreSQL에서 가져옴 }) # PostgreSQL에서 이미지 URL + 도매상 재고 가져오기 if apc_list: try: from sqlalchemy import create_engine pg_engine = create_engine('postgresql://admin:trajet6640@192.168.0.87:5432/apdb_master') with pg_engine.connect() as conn: placeholders = ','.join([f"'{a}'" for a in apc_list]) # 이미지 URL + 분류 조회 info_result = conn.execute(text(f""" SELECT apc, image_url1, llm_pharm->>'분류' as category FROM apc WHERE apc IN ({placeholders}) """)) info_map = {row.apc: {'image_url': row.image_url1, 'category': row.category} for row in info_result} # 도매상 재고 조회 (SUM) stock_result = conn.execute(text(f""" SELECT A.apc, COALESCE(SUM(I.quantity), 0) as wholesaler_stock FROM apc A LEFT JOIN inventory I ON I.apdb_id = A.idx WHERE A.apc IN ({placeholders}) GROUP BY A.apc """)) stock_map = {row.apc: int(row.wholesaler_stock) for row in stock_result} for item in result: if item['apc']: if item['apc'] in info_map: item['image_url'] = info_map[item['apc']]['image_url'] item['category'] = info_map[item['apc']]['category'] if item['apc'] in stock_map: item['wholesaler_stock'] = stock_map[item['apc']] else: item['wholesaler_stock'] = 0 except Exception as e: logging.warning(f"PostgreSQL 이미지/재고 조회 실패: {e}") return result except Exception as e: logging.warning(f"동물약 목록 조회 실패: {e}") return [] @app.route('/api/animal-chat', methods=['POST']) def api_animal_chat(): """ 동물약 추천 챗봇 API Request: { "messages": [ {"role": "user", "content": "강아지 심장사상충 약 추천해주세요"} ] } Response: { "success": true, "message": "AI 응답 텍스트", "products": [{"name": "...", "price": ...}] // 언급된 보유 제품 } """ try: import time from utils.animal_chat_logger import ChatLogEntry, log_chat # 로그 엔트리 초기화 log_entry = ChatLogEntry() total_start = time.time() if not OPENAI_AVAILABLE: return jsonify({ 'success': False, 'message': 'AI 기능을 사용할 수 없습니다. 관리자에게 문의하세요.' }), 503 data = request.get_json() messages = data.get('messages', []) if not messages: return jsonify({ 'success': False, 'message': '메시지를 입력해주세요.' }), 400 # 입력 로깅 last_user_msg = next((m['content'] for m in reversed(messages) if m.get('role') == 'user'), '') log_entry.user_message = last_user_msg log_entry.history_length = len(messages) log_entry.session_id = data.get('session_id', '') # 보유 동물약 목록 조회 (MSSQL) mssql_start = time.time() animal_drugs = _get_animal_drugs() log_entry.mssql_drug_count = len(animal_drugs) log_entry.mssql_duration_ms = int((time.time() - mssql_start) * 1000) # APC가 있는 제품의 상세 정보 조회 (PostgreSQL RAG) pgsql_start = time.time() apc_codes = [d['apc'] for d in animal_drugs if d.get('apc')] rag_data = _get_animal_drug_rag(apc_codes) if apc_codes else {} log_entry.pgsql_rag_count = len(rag_data) log_entry.pgsql_duration_ms = int((time.time() - pgsql_start) * 1000) available_products_text = "" if animal_drugs: product_lines = [] for d in animal_drugs: line = f"- {d['name']} ({d['price']:,.0f}원)" # RAG 정보 추가 if d.get('apc') and d['apc'] in rag_data: info = rag_data[d['apc']] details = [] # 투여방법 표시 (내복/외용 구분) admin_type = "" dosage_form = info.get('dosage_form', '').lower() easy_cat = info.get('easy_category', '').lower() dosage_weight = info.get('dosage_weight', '').lower() # 외용약 판별 (겔, 크림, 연고, 스팟온, 도포, 환부) if any(kw in dosage_form for kw in ['겔', '크림', '연고', '스팟온', '점이', '외용']) or \ any(kw in easy_cat for kw in ['외용', '피부약', '점이', '점안']) or \ any(kw in dosage_weight for kw in ['도포', '환부', '바르']): admin_type = "외용" # 내복약 판별 (정제, 츄어블, 캡슐, 시럽, 경구) elif any(kw in dosage_form for kw in ['정제', '츄어블', '캡슐', '시럽', '산제', '과립', '액제', '경구']) or \ any(kw in easy_cat for kw in ['내복', '경구', '구충', '심장사상충', '정장', '소화']): admin_type = "내복" if admin_type: form_display = info.get('dosage_form', '')[:10] if info.get('dosage_form') else '' cat_display = info.get('easy_category', '')[:15] if info.get('easy_category') else '' details.append(f"💊{admin_type}/{form_display}, {cat_display}") if info.get('target_animals'): details.append(f"대상: {info['target_animals']}") if info.get('main_ingredient'): details.append(f"성분: {info['main_ingredient']}") if info.get('usage_for'): details.append(f"용도: {info['usage_for']}") if info.get('dosage_weight'): details.append(f"용량: {info['dosage_weight']}") if info.get('age_restriction'): details.append(f"금기: {info['age_restriction']}") if details: line += f" [{', '.join(details)}]" product_lines.append(line) available_products_text = f""" **현재 보유 동물약:** {chr(10).join(product_lines)} """ # 상세 질문 감지 (전역으로 사용) detail_keywords = ['자세히', '상세히', '더 알려', '설명해', '왜', '어떻게', '원리', '기전', '성분'] is_detail_request = any(kw in last_user_msg for kw in detail_keywords) # 투여방법 키워드 감지 oral_keywords = ['먹는', '먹이는', '경구', '복용', '츄어블', '정제', '캡슐'] topical_keywords = ['바르는', '도포', '외용', '스팟온', '겔', '크림', '연고'] is_oral_request = any(kw in last_user_msg for kw in oral_keywords) is_topical_request = any(kw in last_user_msg for kw in topical_keywords) # 투여방법 필터 안내 추가 admin_filter_note = "" if is_oral_request and not is_topical_request: admin_filter_note = "\n\n⚠️ **사용자가 '먹는 약/경구/복용' 질문** → 내복약(정제, 츄어블, 캡슐)만 추천! 외용약(겔, 크림, 도포)은 추천하지 마세요!" elif is_topical_request and not is_oral_request: admin_filter_note = "\n\n⚠️ **사용자가 '바르는 약/외용/도포' 질문** → 외용약(겔, 크림, 스팟온)만 추천! 내복약(정제, 츄어블)은 추천하지 마세요!" # 🚨 엔로플록사신/퀴놀론 관련 질문 감지 → 망막 독성 경고 주입 enro_keywords = ['엔로플록사신', '아시엔로', 'asienro', 'enrofloxacin', '퀴놀론', 'fluoroquinolone', 'baytril', '바이트릴'] is_enro_question = any(kw.lower() in last_user_msg.lower() for kw in enro_keywords) enro_warning = "" if is_enro_question: enro_warning = """ 🚨 **[필수 안내] 엔로플록사신 고양이 망막 독성 - 반드시 답변에 포함!** - FDA 부작용 보고: 산동 454건, 실명 399건, 망막장애 244건 - 고양이는 **5mg/kg/day 절대 초과 금지** (권장: 2.5~5mg/kg) - 15mg/kg/day × 21일 → 망막 이상, 30mg/kg/day → 급성 실명 - 탈수/신장질환 시 위험 증가 (약물 배설 지연) - 안전한 대안: Marbofloxacin(마르보플록사신), Pradofloxacin(프라도플록사신) - ⚠️ 이 경고를 답변 첫 부분에 반드시 언급하세요! """ # 벡터 DB 검색 (LanceDB RAG) vector_context = "" vector_start = time.time() try: from utils.animal_rag import get_animal_rag if last_user_msg: n_results = 5 if is_detail_request else 3 rag = get_animal_rag() vector_results = rag.search(last_user_msg, n_results=n_results) log_entry.vector_results_count = len(vector_results) log_entry.vector_top_scores = [r.get('score', 0) for r in vector_results] log_entry.vector_sources = [f"{r.get('source', '')}#{r.get('section', '')}" for r in vector_results] vector_context = rag.get_context_for_chat(last_user_msg, n_results=n_results) # 상세 요청 시 컨텍스트 활용 지시 추가 if is_detail_request and vector_context: vector_context = "⚠️ 아래 RAG 검색 결과를 적극 활용하여 상세하게 답변하세요!\n\n" + vector_context except Exception as e: logging.warning(f"벡터 검색 실패 (무시): {e}") log_entry.vector_duration_ms = int((time.time() - vector_start) * 1000) # System Prompt 구성 knowledge_section = ANIMAL_DRUG_KNOWLEDGE + "\n\n" + vector_context if vector_context else ANIMAL_DRUG_KNOWLEDGE knowledge_section += admin_filter_note # 투여방법 필터 안내 추가 knowledge_section += enro_warning # 🚨 엔로플록사신 망막 독성 경고 추가 system_prompt = ANIMAL_CHAT_SYSTEM_PROMPT.format( available_products=available_products_text, knowledge_base=knowledge_section ) # OpenAI API 호출 openai_start = time.time() client = OpenAI(api_key=OPENAI_API_KEY) api_messages = [{"role": "system", "content": system_prompt}] for msg in messages[-10:]: # 최근 10개 메시지만 api_messages.append({ "role": msg.get("role", "user"), "content": msg.get("content", "") }) # max_tokens 동적 설정 (상세 질문 시 증가) max_tokens = 1500 if is_detail_request else 500 response = client.chat.completions.create( model=OPENAI_MODEL, messages=api_messages, max_tokens=max_tokens, temperature=0.7 ) ai_response = response.choices[0].message.content # OpenAI 로깅 log_entry.openai_model = OPENAI_MODEL log_entry.openai_prompt_tokens = response.usage.prompt_tokens log_entry.openai_completion_tokens = response.usage.completion_tokens log_entry.openai_total_tokens = response.usage.total_tokens log_entry.openai_duration_ms = int((time.time() - openai_start) * 1000) # 응답에서 언급된 보유 제품 찾기 (부분 매칭) mentioned_products = [] # 공백 제거한 버전도 준비 (AI가 띄어쓰기 넣을 수 있음) ai_response_lower = ai_response.lower() ai_response_nospace = ai_response_lower.replace(' ', '') # 제품명 길이순 정렬 (긴 이름 우선 매칭 - "안텔민킹"이 "안텔민"보다 먼저) sorted_drugs = sorted(animal_drugs, key=lambda x: len(x['name']), reverse=True) for drug in sorted_drugs: drug_name = drug['name'] # (판) 같은 접두어 제거 clean_name = drug_name if clean_name.startswith('(판)'): clean_name = clean_name[3:] # 제품명에서 핵심 키워드 추출 (괄호 앞부분) # 예: "다이로하트정M(12~22kg)" → "다이로하트" base_name = clean_name.split('(')[0].split('/')[0].strip() # 사이즈 제거: "다이로하트정M" → "다이로하트" for suffix in ['정', '액', 'L', 'M', 'S', 'XL', 'XS', 'SS', 'mini']: if base_name.endswith(suffix): base_name = base_name[:-len(suffix)] base_name = base_name.strip() # 핵심 키워드가 AI 응답에 포함되어 있는지 확인 # 공백 있는 버전과 없는 버전 모두 체크 base_lower = base_name.lower() base_nospace = base_lower.replace(' ', '') # 기본 매칭 matched = (len(base_name) >= 2 and (base_lower in ai_response_lower or base_nospace in ai_response_nospace)) # 추가: 상위 키워드 매칭 (예: "안텔민" 언급 시 "안텔민킹", "안텔민뽀삐"도 매칭) # APC 있는 제품 우선 if not matched and drug.get('apc'): # 제품명의 핵심 부분 추출 (예: "안텔민킹" → "안텔민") core_name = base_nospace.rstrip('킹뽀삐') if len(core_name) >= 2 and core_name in ai_response_nospace: matched = True if matched: # 중복 방지: 이미 추가된 더 specific한 제품이 있으면 건너뜀 # 예: "안텔민킹" 추가됨 → "안텔민" 건너뜀 already_covered = any( base_nospace in p['name'].lower().replace(' ', '') or p['name'].lower().replace(' ', '').startswith(base_nospace) for p in mentioned_products ) if not already_covered: mentioned_products.append({ 'name': drug_name, 'price': drug['price'], 'code': drug['code'], 'image_url': drug.get('image_url'), # APC 이미지 URL 'stock': drug.get('stock', 0), # 약국 재고 'wholesaler_stock': drug.get('wholesaler_stock', 0), # 도매상 재고 'category': drug.get('category') # 분류 (내부구충제, 심장사상충약 등) }) # 최종 로깅 log_entry.assistant_response = ai_response log_entry.products_mentioned = [p['name'] for p in mentioned_products[:5]] log_entry.total_duration_ms = int((time.time() - total_start) * 1000) log_chat(log_entry) return jsonify({ 'success': True, 'message': ai_response, 'products': mentioned_products[:5], # 최대 5개 'usage': { 'input_tokens': response.usage.prompt_tokens, 'output_tokens': response.usage.completion_tokens } }) except RateLimitError as e: log_entry.error = f"RateLimitError: {e}" log_entry.total_duration_ms = int((time.time() - total_start) * 1000) log_chat(log_entry) return jsonify({ 'success': False, 'message': 'AI 사용량 한도에 도달했습니다. 잠시 후 다시 시도해주세요.' }), 429 except APITimeoutError as e: log_entry.error = f"APITimeoutError: {e}" log_entry.total_duration_ms = int((time.time() - total_start) * 1000) log_chat(log_entry) return jsonify({ 'success': False, 'message': 'AI 응답 시간이 초과되었습니다. 다시 시도해주세요.' }), 504 except Exception as e: logging.error(f"동물약 챗봇 오류: {e}") log_entry.error = str(e) log_entry.total_duration_ms = int((time.time() - total_start) * 1000) log_chat(log_entry) return jsonify({ 'success': False, 'message': f'오류가 발생했습니다: {str(e)}' }), 500 @app.route('/api/animal-drugs') def api_animal_drugs(): """보유 동물약 목록 API""" try: drugs = _get_animal_drugs() return jsonify({ 'success': True, 'items': drugs, 'count': len(drugs) }) except Exception as e: return jsonify({ 'success': False, 'error': str(e) }), 500 # ===== 제품 검색 페이지 ===== @app.route('/admin/products') def admin_products(): """제품 검색 페이지 (전체 재고에서 검색, QR 인쇄)""" return render_template('admin_products.html') @app.route('/admin/drug-usage') def admin_drug_usage(): """기간별 사용약품 조회 페이지""" return render_template('admin_drug_usage.html') @app.route('/admin/members') def admin_members(): """회원 검색 페이지 (팜IT3000 CD_PERSON, 알림톡/SMS 발송)""" return render_template('admin_members.html') @app.route('/api/products') def api_products(): """ 제품 검색 API - 상품명, 상품코드, 바코드로 검색 - 세트상품 바코드도 CD_ITEM_UNIT_MEMBER에서 조회 - in_stock_only: 사용약품만 (IM_total에 재고 있는 제품) """ search = request.args.get('search', '').strip() limit = int(request.args.get('limit', 100)) animal_only = request.args.get('animal_only', '0') == '1' in_stock_only = request.args.get('in_stock_only', '0') == '1' # 동물약만 체크시 검색어 없어도 전체 조회 가능 if not animal_only and (not search or len(search) < 2): return jsonify({'success': False, 'error': '검색어는 2글자 이상 입력하세요'}) try: drug_session = db_manager.get_session('PM_DRUG') # WHERE 조건 생성 (동물약 전체 조회 시 검색 조건 없음) search_condition = "" if search: search_condition = """ AND (G.GoodsName LIKE :search_like OR G.DrugCode LIKE :search_like OR G.BARCODE LIKE :search_like) """ # 동물약만 필터 (쿼리에서 직접 처리) animal_condition = "AND G.POS_BOON = '010103'" if animal_only else "" # 제품 검색 쿼리 - 사용약품만 옵션에 따라 JOIN 방식 변경 if in_stock_only: # 최적화된 쿼리: 재고 있는 제품만 (IM_total INNER JOIN) # 대표바코드만 표시, 단위바코드 첫번째/갯수, APC(02%) products_query = text(f""" SELECT TOP {limit} G.DrugCode as drug_code, G.GoodsName as product_name, ISNULL(NULLIF(G.BARCODE, ''), '') as barcode, G.Saleprice as sale_price, G.Price as cost_price, ISNULL(G.SplName, '') as supplier, 0 as is_set, G.POS_BOON as pos_boon, IT.IM_QT_sale_debit as stock, ISNULL(POS.CD_NM_sale, '') as location, APC.CD_CD_BARCODE as apc_code, UNIT_FIRST.CD_CD_BARCODE as unit_barcode, ISNULL(UNIT_CNT.cnt, 0) as unit_barcode_count FROM CD_GOODS G INNER JOIN IM_total IT ON G.DrugCode = IT.DrugCode AND IT.IM_QT_sale_debit > 0 LEFT JOIN CD_item_position POS ON G.DrugCode = POS.DrugCode OUTER APPLY ( SELECT TOP 1 CD_CD_BARCODE FROM CD_ITEM_UNIT_MEMBER WHERE DRUGCODE = G.DrugCode AND CD_CD_BARCODE LIKE '02%' ) APC OUTER APPLY ( SELECT TOP 1 CD_CD_BARCODE FROM CD_ITEM_UNIT_MEMBER WHERE DRUGCODE = G.DrugCode AND CD_CD_BARCODE IS NOT NULL AND CD_CD_BARCODE != '' ) UNIT_FIRST OUTER APPLY ( SELECT COUNT(*) as cnt FROM CD_ITEM_UNIT_MEMBER WHERE DRUGCODE = G.DrugCode AND CD_CD_BARCODE IS NOT NULL AND CD_CD_BARCODE != '' ) UNIT_CNT WHERE 1=1 {animal_condition} {search_condition} ORDER BY G.GoodsName """) else: # 전체 쿼리 (OUTER APPLY 포함, 느림) # 동물약만 조회 시 OUTER APPLY 생략 가능 if animal_only: products_query = text(f""" SELECT TOP {limit} G.DrugCode as drug_code, G.GoodsName as product_name, ISNULL(NULLIF(G.BARCODE, ''), '') as barcode, G.Saleprice as sale_price, G.Price as cost_price, ISNULL(G.SplName, '') as supplier, 0 as is_set, G.POS_BOON as pos_boon, ISNULL(IT.IM_QT_sale_debit, 0) as stock, ISNULL(POS.CD_NM_sale, '') as location, APC.CD_CD_BARCODE as apc_code, UNIT_FIRST.CD_CD_BARCODE as unit_barcode, ISNULL(UNIT_CNT.cnt, 0) as unit_barcode_count FROM CD_GOODS G LEFT JOIN IM_total IT ON G.DrugCode = IT.DrugCode LEFT JOIN CD_item_position POS ON G.DrugCode = POS.DrugCode OUTER APPLY ( SELECT TOP 1 CD_CD_BARCODE FROM CD_ITEM_UNIT_MEMBER WHERE DRUGCODE = G.DrugCode AND CD_CD_BARCODE LIKE '02%' ) APC OUTER APPLY ( SELECT TOP 1 CD_CD_BARCODE FROM CD_ITEM_UNIT_MEMBER WHERE DRUGCODE = G.DrugCode AND CD_CD_BARCODE IS NOT NULL AND CD_CD_BARCODE != '' ) UNIT_FIRST OUTER APPLY ( SELECT COUNT(*) as cnt FROM CD_ITEM_UNIT_MEMBER WHERE DRUGCODE = G.DrugCode AND CD_CD_BARCODE IS NOT NULL AND CD_CD_BARCODE != '' ) UNIT_CNT WHERE G.POS_BOON = '010103' {search_condition} ORDER BY G.GoodsName """) else: products_query = text(f""" SELECT TOP {limit} G.DrugCode as drug_code, G.GoodsName as product_name, ISNULL(NULLIF(G.BARCODE, ''), '') as barcode, G.Saleprice as sale_price, G.Price as cost_price, CASE WHEN G.SplName IS NOT NULL AND G.SplName != '' THEN G.SplName WHEN SET_CHK.is_set = 1 THEN N'세트상품' ELSE '' END as supplier, CASE WHEN SET_CHK.is_set = 1 THEN 1 ELSE 0 END as is_set, G.POS_BOON as pos_boon, ISNULL(IT.IM_QT_sale_debit, 0) as stock, ISNULL(POS.CD_NM_sale, '') as location, APC.CD_CD_BARCODE as apc_code, UNIT_FIRST.CD_CD_BARCODE as unit_barcode, ISNULL(UNIT_CNT.cnt, 0) as unit_barcode_count FROM CD_GOODS G LEFT JOIN IM_total IT ON G.DrugCode = IT.DrugCode LEFT JOIN CD_item_position POS ON G.DrugCode = POS.DrugCode OUTER APPLY ( SELECT TOP 1 1 as is_set FROM CD_item_set WHERE SetCode = G.DrugCode AND DrugCode = 'SET0000' ) SET_CHK OUTER APPLY ( SELECT TOP 1 CD_CD_BARCODE FROM CD_ITEM_UNIT_MEMBER WHERE DRUGCODE = G.DrugCode AND CD_CD_BARCODE LIKE '02%' ) APC OUTER APPLY ( SELECT TOP 1 CD_CD_BARCODE FROM CD_ITEM_UNIT_MEMBER WHERE DRUGCODE = G.DrugCode AND CD_CD_BARCODE IS NOT NULL AND CD_CD_BARCODE != '' ) UNIT_FIRST OUTER APPLY ( SELECT COUNT(*) as cnt FROM CD_ITEM_UNIT_MEMBER WHERE DRUGCODE = G.DrugCode AND CD_CD_BARCODE IS NOT NULL AND CD_CD_BARCODE != '' ) UNIT_CNT WHERE 1=1 {search_condition} ORDER BY G.GoodsName """) # 파라미터 설정 (검색어가 있을 때만) params = {} if search: params['search_like'] = f'%{search}%' rows = drug_session.execute(products_query, params).fetchall() items = [] for row in rows: is_animal = row.pos_boon == '010103' # APC 조회 (동물약인 경우) apc = None if is_animal: try: apc_result = drug_session.execute(text(""" SELECT TOP 1 CD_CD_BARCODE FROM CD_ITEM_UNIT_MEMBER WHERE DRUGCODE = :drug_code AND (CD_CD_BARCODE LIKE '02%' OR CD_CD_BARCODE LIKE '92%') AND LEN(CD_CD_BARCODE) = 13 """), {'drug_code': row.drug_code}) apc_row = apc_result.fetchone() if apc_row: apc = apc_row[0] except: pass # APC 코드: 쿼리에서 02%로 조회한 것만 사용 (바코드 대체 X) apc_code = getattr(row, 'apc_code', None) or '' # 단위바코드 (첫 번째, 갯수) unit_barcode = getattr(row, 'unit_barcode', None) or '' unit_barcode_count = getattr(row, 'unit_barcode_count', 0) or 0 # PostgreSQL 조회용 APC (분류/도매재고): apc 또는 apc_code 또는 unit_barcode pg_apc = apc or apc_code or unit_barcode items.append({ 'drug_code': row.drug_code or '', 'product_name': row.product_name or '', 'barcode': row.barcode or '', 'sale_price': float(row.sale_price) if row.sale_price else 0, 'cost_price': float(row.cost_price) if row.cost_price else 0, 'supplier': row.supplier or '', 'is_set': bool(row.is_set), 'is_animal_drug': is_animal, 'stock': int(row.stock) if row.stock else 0, 'location': row.location or '', # 위치 'apc': apc_code, # UI용 APC 코드 (02로 시작하는 것만) 'unit_barcode': unit_barcode, # 단위바코드 첫 번째 (QR용) 'unit_barcode_count': int(unit_barcode_count), # 단위바코드 갯수 (뱃지용) '_pg_apc': pg_apc, # PostgreSQL 조회용 (내부용) 'category': None, # PostgreSQL에서 lazy fetch 'wholesaler_stock': None, 'thumbnail': None # 아래에서 채움 }) # 동물약 분류 Lazy Fetch (PostgreSQL) - 실패해도 무시 animal_items = [i for i in items if i['is_animal_drug'] and i.get('_pg_apc')] if animal_items: try: from sqlalchemy import create_engine pg_engine = create_engine('postgresql://admin:trajet6640@192.168.0.87:5432/apdb_master', connect_args={'connect_timeout': 3}) # 3초 타임아웃 with pg_engine.connect() as conn: apc_list = [i['apc'] for i in animal_items if i['apc']] if apc_list: placeholders = ','.join([f"'{a}'" for a in apc_list]) # 분류 + 도매상 재고 조회 result = conn.execute(text(f""" SELECT A.apc, A.llm_pharm->>'분류' as category, COALESCE(SUM(I.quantity), 0) as wholesaler_stock FROM apc A LEFT JOIN inventory I ON I.apdb_id = A.idx WHERE A.apc IN ({placeholders}) GROUP BY A.apc, A.llm_pharm """)) pg_map = {row.apc: {'category': row.category, 'ws': int(row.wholesaler_stock)} for row in result} for item in items: if item['apc'] and item['apc'] in pg_map: item['category'] = pg_map[item['apc']]['category'] item['wholesaler_stock'] = pg_map[item['apc']]['ws'] except Exception as pg_err: logging.warning(f"PostgreSQL 분류 조회 실패 (무시): {pg_err}") # PostgreSQL 실패해도 MSSQL 데이터는 정상 반환 # 제품 이미지 조회 (product_images.db) try: images_db_path = Path(__file__).parent / 'db' / 'product_images.db' if images_db_path.exists(): img_conn = sqlite3.connect(str(images_db_path)) img_cursor = img_conn.cursor() barcodes = [item['barcode'] for item in items if item['barcode']] drug_codes = [item['drug_code'] for item in items if item['drug_code']] image_map = {} if barcodes: placeholders = ','.join(['?' for _ in barcodes]) img_cursor.execute(f''' SELECT barcode, thumbnail_base64 FROM product_images WHERE barcode IN ({placeholders}) AND thumbnail_base64 IS NOT NULL ''', barcodes) for row in img_cursor.fetchall(): image_map[f'bc:{row[0]}'] = row[1] if drug_codes: placeholders = ','.join(['?' for _ in drug_codes]) img_cursor.execute(f''' SELECT drug_code, thumbnail_base64 FROM product_images WHERE drug_code IN ({placeholders}) AND thumbnail_base64 IS NOT NULL ''', drug_codes) for row in img_cursor.fetchall(): if f'dc:{row[0]}' not in image_map: image_map[f'dc:{row[0]}'] = row[1] img_conn.close() for item in items: thumb = image_map.get(f'bc:{item["barcode"]}') or image_map.get(f'dc:{item["drug_code"]}') if thumb: item['thumbnail'] = thumb except Exception as img_err: logging.warning(f"제품 이미지 조회 오류: {img_err}") return jsonify({ 'success': True, 'items': items, 'count': len(items) }) except Exception as e: logging.error(f"제품 검색 오류: {e}") return jsonify({'success': False, 'error': str(e)}), 500 # ==================== 입고이력 API ==================== @app.route('/api/drugs//purchase-history') def api_drug_purchase_history(drug_code): """ 약품 입고이력 조회 API - WH_sub: 입고 상세 (약품코드, 수량, 단가) - WH_main: 입고 마스터 (입고일, 도매상코드) - PM_BASE.CD_custom: 도매상명 """ try: drug_session = db_manager.get_session('PM_DRUG') # 입고이력 조회 (최근 100건) result = drug_session.execute(text(""" SELECT TOP 100 m.WH_DT_appl as purchase_date, COALESCE(c.CD_NM_custom, m.WH_BUSINAME, '미확인') as supplier_name, CAST(COALESCE(s.WH_NM_item_a, 0) AS INT) as quantity, CAST(COALESCE(s.WH_MY_unit_a, 0) AS INT) as unit_price, c.CD_TEL_charge1 as supplier_tel FROM WH_sub s JOIN WH_main m ON m.WH_NO_stock = s.WH_SR_stock AND m.WH_DT_appl = s.WH_DT_appl LEFT JOIN PM_BASE.dbo.CD_custom c ON m.WH_CD_cust_sale = c.CD_CD_custom WHERE s.DrugCode = :drug_code ORDER BY m.WH_DT_appl DESC """), {'drug_code': drug_code}) history = [] for row in result.fetchall(): # 날짜 포맷팅 (YYYYMMDD -> YYYY-MM-DD) date_str = row.purchase_date or '' if len(date_str) == 8: date_str = f"{date_str[:4]}-{date_str[4:6]}-{date_str[6:8]}" history.append({ 'date': date_str, 'supplier': row.supplier_name or '미확인', 'quantity': row.quantity or 0, 'unit_price': row.unit_price or 0, 'supplier_tel': row.supplier_tel or '' }) # 약품명 조회 name_result = drug_session.execute(text(""" SELECT GoodsName FROM CD_GOODS WHERE DrugCode = :drug_code """), {'drug_code': drug_code}) name_row = name_result.fetchone() drug_name = name_row[0] if name_row else drug_code return jsonify({ 'success': True, 'drug_code': drug_code, 'drug_name': drug_name, 'history': history, 'count': len(history) }) except Exception as e: logging.error(f"입고이력 조회 오류: {e}") return jsonify({'success': False, 'error': str(e)}), 500 # ==================== 처방 사용이력 API ==================== @app.route('/api/products//usage-history') def api_product_usage_history(drug_code): """ 제품 처방 사용이력 조회 API - PS_main + PS_sub_pharm JOIN - 페이지네이션, 기간 필터 지원 - 환자명 마스킹 처리 """ page = int(request.args.get('page', 1)) per_page = int(request.args.get('per_page', 20)) months = int(request.args.get('months', 12)) offset = (page - 1) * per_page try: pres_session = db_manager.get_session('PM_PRES') # 기간 계산 (N개월 전부터) from datetime import datetime, timedelta start_date = (datetime.now() - timedelta(days=months * 30)).strftime('%Y%m%d') # 총 건수 조회 (COUNT) count_result = pres_session.execute(text(""" SELECT COUNT(*) as total_count FROM PS_sub_pharm sp JOIN PS_main pm ON pm.PreSerial = sp.PreSerial WHERE sp.DrugCode = :drug_code AND pm.Indate >= :start_date AND (sp.PS_Type IS NULL OR sp.PS_Type != '9') """), {'drug_code': drug_code, 'start_date': start_date}) total_count = count_result.fetchone()[0] # 데이터 조회 (페이지네이션) data_result = pres_session.execute(text(""" SELECT pm.Paname as patient_name, pm.CusCode as cus_code, pm.Indate as rx_date, sp.QUAN as quantity, sp.QUAN_TIME as times, sp.Days as days FROM PS_sub_pharm sp JOIN PS_main pm ON pm.PreSerial = sp.PreSerial WHERE sp.DrugCode = :drug_code AND pm.Indate >= :start_date AND (sp.PS_Type IS NULL OR sp.PS_Type != '9') ORDER BY pm.Indate DESC OFFSET :offset ROWS FETCH NEXT :per_page ROWS ONLY """), { 'drug_code': drug_code, 'start_date': start_date, 'offset': offset, 'per_page': per_page }) items = [] for row in data_result.fetchall(): patient_name = row.patient_name or '' cus_code = row.cus_code or '' # 날짜 포맷팅 (YYYYMMDD -> YYYY-MM-DD) rx_date = row.rx_date or '' if len(rx_date) == 8: rx_date = f"{rx_date[:4]}-{rx_date[4:6]}-{rx_date[6:8]}" quantity = int(row.quantity) if row.quantity else 1 times = int(row.times) if row.times else 1 # 횟수 (QUAN_TIME) days = int(row.days) if row.days else 1 total_dose = quantity * times * days # 수량 × 횟수 × 일수 items.append({ 'patient_name': patient_name, 'cus_code': cus_code, 'rx_date': rx_date, 'quantity': quantity, 'times': times, 'days': days, 'total_dose': total_dose }) # 약품명 조회 drug_session = db_manager.get_session('PM_DRUG') name_result = drug_session.execute(text(""" SELECT GoodsName FROM CD_GOODS WHERE DrugCode = :drug_code """), {'drug_code': drug_code}) name_row = name_result.fetchone() product_name = name_row[0] if name_row else drug_code # 총 페이지 수 계산 total_pages = (total_count + per_page - 1) // per_page return jsonify({ 'success': True, 'product_name': product_name, 'pagination': { 'page': page, 'per_page': per_page, 'total_count': total_count, 'total_pages': total_pages }, 'items': items }) except Exception as e: logging.error(f"사용이력 조회 오류: {e}") return jsonify({'success': False, 'error': str(e)}), 500 @app.route('/api/patients//recent-prescriptions') def api_patient_recent_prescriptions(cus_code): """ 환자 최근 처방 내역 조회 API - 해당 환자가 최근에 어떤 약을 처방받았는지 확인 - DB 부담 최소화: 최근 6개월, 최대 30건 """ try: pres_session = db_manager.get_session('PM_PRES') drug_session = db_manager.get_session('PM_DRUG') # 최근 6개월 from datetime import datetime, timedelta start_date = (datetime.now() - timedelta(days=180)).strftime('%Y%m%d') # 환자의 최근 처방전 조회 (최대 10건) rx_result = pres_session.execute(text(""" SELECT TOP 10 pm.PreSerial, pm.Paname as patient_name, pm.Indate as rx_date, pm.Drname as doctor_name, pm.OrderName as hospital_name FROM PS_main pm WHERE pm.CusCode = :cus_code AND pm.Indate >= :start_date ORDER BY pm.Indate DESC """), {'cus_code': cus_code, 'start_date': start_date}) prescriptions = [] for rx in rx_result.fetchall(): # 날짜 포맷팅 rx_date = rx.rx_date or '' if len(rx_date) == 8: rx_date = f"{rx_date[:4]}-{rx_date[4:6]}-{rx_date[6:8]}" # 해당 처방의 약품 목록 조회 items_result = pres_session.execute(text(""" SELECT sp.DrugCode, sp.QUAN as quantity, sp.QUAN_TIME as times, sp.Days as days FROM PS_sub_pharm sp WHERE sp.PreSerial = :pre_serial AND (sp.PS_Type IS NULL OR sp.PS_Type != '9') """), {'pre_serial': rx.PreSerial}) # 먼저 모든 약품 데이터를 리스트로 가져오기 raw_items = items_result.fetchall() drug_codes = [item.DrugCode for item in raw_items] # 약품명 + 성분명 + 분류(PRINT_TYPE) 일괄 조회 drug_info_map = {} if drug_codes: placeholders = ','.join([f"'{dc}'" for dc in drug_codes]) name_result = drug_session.execute(text(f""" SELECT g.DrugCode, g.GoodsName, s.SUNG_HNM, m.PRINT_TYPE FROM CD_GOODS g LEFT JOIN CD_SUNG s ON g.SUNG_CODE = s.SUNG_CODE LEFT JOIN CD_MC m ON g.DrugCode = m.DRUGCODE WHERE g.DrugCode IN ({placeholders}) """)) for row in name_result.fetchall(): drug_info_map[row[0]] = { 'name': row[1], 'ingredient': row[2] or '', 'category': row[3] or '' # 분류 (알러지질환약 등) } items = [] for item in raw_items: info = drug_info_map.get(item.DrugCode, {}) drug_name = info.get('name', item.DrugCode) ingredient = info.get('ingredient', '') category = info.get('category', '') # 분류 (알러지질환약 등) quantity = int(item.quantity) if item.quantity else 1 times = int(item.times) if item.times else 1 days = int(item.days) if item.days else 1 items.append({ 'drug_code': item.DrugCode, 'drug_name': drug_name, 'category': category, # 분류 추가 'quantity': quantity, 'times': times, 'days': days, 'total_dose': quantity * times * days }) prescriptions.append({ 'pre_serial': rx.PreSerial, 'rx_date': rx_date, 'doctor_name': rx.doctor_name or '', 'hospital_name': rx.hospital_name or '', 'items': items }) # 환자명 patient_name = prescriptions[0]['items'][0]['drug_name'] if prescriptions else '' if prescriptions and pres_session.execute(text(""" SELECT TOP 1 Paname FROM PS_main WHERE CusCode = :cus_code """), {'cus_code': cus_code}).fetchone(): patient_name = pres_session.execute(text(""" SELECT TOP 1 Paname FROM PS_main WHERE CusCode = :cus_code """), {'cus_code': cus_code}).fetchone()[0] return jsonify({ 'success': True, 'cus_code': cus_code, 'patient_name': patient_name, 'prescription_count': len(prescriptions), 'prescriptions': prescriptions }) except Exception as e: logging.error(f"환자 처방 조회 오류: {e}") return jsonify({'success': False, 'error': str(e)}), 500 # ==================== 위치 정보 API ==================== @app.route('/api/locations') def api_get_all_locations(): """모든 위치명 목록 조회""" try: drug_session = db_manager.get_session('PM_DRUG') result = drug_session.execute(text(""" SELECT DISTINCT CD_NM_sale FROM CD_item_position WHERE CD_NM_sale IS NOT NULL AND CD_NM_sale != '' ORDER BY CD_NM_sale """)) locations = [row[0] for row in result.fetchall()] return jsonify({ 'success': True, 'locations': locations, 'total': len(locations) }) except Exception as e: logging.error(f"위치 목록 조회 오류: {e}") return jsonify({'success': False, 'error': str(e)}), 500 @app.route('/api/drugs//location', methods=['PUT']) def api_update_drug_location(drug_code): """약품 위치 업데이트""" try: data = request.get_json() location_name = data.get('location_name', '').strip() if data else '' # 위치명 길이 검증 (최대 20자) if location_name and len(location_name) > 20: return jsonify({'success': False, 'error': '위치명은 20자를 초과할 수 없습니다'}), 400 drug_session = db_manager.get_session('PM_DRUG') # 기존 레코드 확인 existing = drug_session.execute(text(""" SELECT DrugCode FROM CD_item_position WHERE DrugCode = :drug_code """), {'drug_code': drug_code}).fetchone() if existing: # UPDATE if location_name: drug_session.execute(text(""" UPDATE CD_item_position SET CD_NM_sale = :location WHERE DrugCode = :drug_code """), {'location': location_name, 'drug_code': drug_code}) else: # 빈 값이면 삭제 drug_session.execute(text(""" DELETE FROM CD_item_position WHERE DrugCode = :drug_code """), {'drug_code': drug_code}) else: # INSERT (위치가 있을 때만) if location_name: drug_session.execute(text(""" INSERT INTO CD_item_position (DrugCode, CD_NM_sale) VALUES (:drug_code, :location) """), {'drug_code': drug_code, 'location': location_name}) drug_session.commit() return jsonify({ 'success': True, 'message': '위치 정보가 업데이트되었습니다', 'location': location_name }) except Exception as e: logging.error(f"위치 업데이트 오류: {e}") return jsonify({'success': False, 'error': str(e)}), 500 @app.route('/admin/sales') def admin_sales_pos(): """판매 내역 페이지 (POS 스타일, 거래별 그룹핑)""" return render_template('admin_sales_pos.html') @app.route('/api/sales-detail') def api_sales_detail(): """ 판매 상세 조회 API (바코드 포함) GET /api/sales-detail?days=7&search=타이레놀&barcode=has&customer=홍길동 """ try: days = int(request.args.get('days', 7)) search = request.args.get('search', '').strip() barcode_filter = request.args.get('barcode', 'all') # all, has, none mssql_session = db_manager.get_session('PM_PRES') drug_session = db_manager.get_session('PM_DRUG') # 판매 내역 조회 (최근 N일) # CD_GOODS.BARCODE가 없으면 CD_ITEM_UNIT_MEMBER.CD_CD_BARCODE 사용 (세트상품/자체등록 바코드) # 세트상품 여부 확인: CD_item_set에 SetCode로 존재하면 세트상품 sales_query = text(""" SELECT S.SL_DT_appl as sale_date, S.SL_NO_order as item_order, S.DrugCode as drug_code, ISNULL(G.GoodsName, '알 수 없음') as product_name, COALESCE(NULLIF(G.BARCODE, ''), U.CD_CD_BARCODE, '') as barcode, CASE WHEN G.SplName IS NOT NULL AND G.SplName != '' THEN G.SplName WHEN SET_CHK.is_set = 1 THEN '세트상품' ELSE '' END as supplier, ISNULL(S.QUAN, 1) as quantity, ISNULL(S.SL_TOTAL_PRICE, 0) as total_price_db, ISNULL(G.Saleprice, 0) as unit_price FROM SALE_SUB S LEFT JOIN PM_DRUG.dbo.CD_GOODS G ON S.DrugCode = G.DrugCode OUTER APPLY ( SELECT TOP 1 CD_CD_BARCODE FROM PM_DRUG.dbo.CD_ITEM_UNIT_MEMBER WHERE DRUGCODE = S.DrugCode AND CD_CD_BARCODE IS NOT NULL AND CD_CD_BARCODE != '' ) U OUTER APPLY ( SELECT TOP 1 1 as is_set FROM PM_DRUG.dbo.CD_item_set WHERE SetCode = S.DrugCode AND DrugCode = 'SET0000' ) SET_CHK WHERE S.SL_DT_appl >= CONVERT(VARCHAR(8), DATEADD(DAY, -:days, GETDATE()), 112) ORDER BY S.SL_DT_appl DESC, S.SL_NO_order DESC """) rows = mssql_session.execute(sales_query, {'days': days}).fetchall() items = [] total_amount = 0 barcode_count = 0 unique_products = set() for row in rows: drug_code = row.drug_code or '' product_name = row.product_name or '' barcode = row.barcode or '' # 검색 필터 (상품명, 코드, 바코드) if search: search_lower = search.lower() if (search_lower not in product_name.lower() and search_lower not in drug_code.lower() and search_lower not in barcode.lower()): continue # 바코드 필터 if barcode_filter == 'has' and not barcode: continue if barcode_filter == 'none' and barcode: continue # 표준코드 조회 (CD_BARCODE 테이블) standard_code = '' if barcode: try: std_result = drug_session.execute(text(""" SELECT BASECODE FROM CD_BARCODE WHERE BARCODE = :barcode """), {'barcode': barcode}).fetchone() if std_result and std_result[0]: standard_code = std_result[0] except: pass quantity = int(row.quantity or 1) unit_price = float(row.unit_price or 0) total_price_from_db = float(row.total_price_db or 0) # DB에 합계가 있으면 사용, 없으면 계산 total_price = total_price_from_db if total_price_from_db > 0 else (quantity * unit_price) # 날짜 포맷팅 sale_date_str = str(row.sale_date or '') if len(sale_date_str) == 8: sale_date_str = f"{sale_date_str[:4]}-{sale_date_str[4:6]}-{sale_date_str[6:]}" items.append({ 'sale_date': sale_date_str, 'drug_code': drug_code, 'product_name': product_name, 'barcode': barcode, 'standard_code': standard_code, 'supplier': row.supplier or '', 'quantity': quantity, 'unit_price': int(unit_price), 'total_price': int(total_price), 'thumbnail': None # 나중에 채워짐 }) total_amount += total_price if barcode: barcode_count += 1 unique_products.add(drug_code) # 제품 이미지 조회 (product_images.db에서) try: images_db_path = Path(__file__).parent / 'db' / 'product_images.db' if images_db_path.exists(): img_conn = sqlite3.connect(str(images_db_path)) img_cursor = img_conn.cursor() # barcode와 drug_code 수집 barcodes = [item['barcode'] for item in items if item['barcode']] drug_codes = [item['drug_code'] for item in items if item['drug_code']] # 이미지 조회 (barcode 또는 drug_code로 매칭) image_map = {} if barcodes: placeholders = ','.join(['?' for _ in barcodes]) img_cursor.execute(f''' SELECT barcode, thumbnail_base64 FROM product_images WHERE barcode IN ({placeholders}) AND thumbnail_base64 IS NOT NULL ''', barcodes) for row in img_cursor.fetchall(): image_map[f'bc:{row[0]}'] = row[1] if drug_codes: placeholders = ','.join(['?' for _ in drug_codes]) img_cursor.execute(f''' SELECT drug_code, thumbnail_base64 FROM product_images WHERE drug_code IN ({placeholders}) AND thumbnail_base64 IS NOT NULL ''', drug_codes) for row in img_cursor.fetchall(): if f'dc:{row[0]}' not in image_map: # barcode 우선 image_map[f'dc:{row[0]}'] = row[1] img_conn.close() # 아이템에 썸네일 매핑 for item in items: thumb = image_map.get(f'bc:{item["barcode"]}') or image_map.get(f'dc:{item["drug_code"]}') if thumb: item['thumbnail'] = thumb except Exception as img_err: logging.warning(f"제품 이미지 조회 오류: {img_err}") # 바코드 매핑률 계산 barcode_rate = round(barcode_count / len(items) * 100, 1) if items else 0 # 이미지 매핑률 계산 image_count = sum(1 for item in items if item.get('thumbnail')) image_rate = round(image_count / len(items) * 100, 1) if items else 0 return jsonify({ 'success': True, 'items': items[:500], # 최대 500건 'stats': { 'total_count': len(items), 'total_amount': int(total_amount), 'barcode_rate': barcode_rate, 'image_rate': image_rate, 'unique_products': len(unique_products) } }) except Exception as e: logging.error(f"판매 상세 조회 오류: {e}") return jsonify({ 'success': False, 'error': str(e) }), 500 # ===== 사용량 조회 페이지 및 API ===== @app.route('/admin/usage') def admin_usage(): """OTC 사용량 조회 · 주문 페이지""" return render_template('admin_usage.html') @app.route('/admin/rx-usage') def admin_rx_usage(): """전문의약품 사용량 조회 · 주문 페이지""" return render_template('admin_rx_usage.html') @app.route('/api/usage') def api_usage(): """ 기간별 품목 사용량 조회 API GET /api/usage?start_date=2026-01-01&end_date=2026-01-31&search=타이레놀&sort=qty_desc """ try: start_date = request.args.get('start_date', '') end_date = request.args.get('end_date', '') search = request.args.get('search', '').strip() sort = request.args.get('sort', 'qty_desc') # qty_desc, qty_asc, name_asc, amount_desc # 날짜 형식 변환 (YYYY-MM-DD -> YYYYMMDD) start_date_fmt = start_date.replace('-', '') if start_date else datetime.now().strftime('%Y%m%d') end_date_fmt = end_date.replace('-', '') if end_date else datetime.now().strftime('%Y%m%d') mssql_session = db_manager.get_session('PM_PRES') # 품목별 사용량 집계 쿼리 usage_query = text(""" SELECT S.DrugCode as drug_code, ISNULL(G.GoodsName, '알 수 없음') as product_name, CASE WHEN G.SplName IS NOT NULL AND G.SplName != '' THEN G.SplName WHEN SET_CHK.is_set = 1 THEN '세트상품' ELSE '' END as supplier, SUM(ISNULL(S.QUAN, 1)) as total_qty, SUM(ISNULL(S.SL_TOTAL_PRICE, 0)) as total_amount, COALESCE(NULLIF(G.BARCODE, ''), U.CD_CD_BARCODE, '') as barcode FROM SALE_SUB S LEFT JOIN PM_DRUG.dbo.CD_GOODS G ON S.DrugCode = G.DrugCode OUTER APPLY ( SELECT TOP 1 CD_CD_BARCODE FROM PM_DRUG.dbo.CD_ITEM_UNIT_MEMBER WHERE DRUGCODE = S.DrugCode AND CD_CD_BARCODE IS NOT NULL AND CD_CD_BARCODE != '' ) U OUTER APPLY ( SELECT TOP 1 1 as is_set FROM PM_DRUG.dbo.CD_item_set WHERE SetCode = S.DrugCode AND DrugCode = 'SET0000' ) SET_CHK WHERE S.SL_DT_appl >= :start_date AND S.SL_DT_appl <= :end_date GROUP BY S.DrugCode, G.GoodsName, G.SplName, SET_CHK.is_set, G.BARCODE, U.CD_CD_BARCODE ORDER BY SUM(ISNULL(S.QUAN, 1)) DESC """) rows = mssql_session.execute(usage_query, { 'start_date': start_date_fmt, 'end_date': end_date_fmt }).fetchall() items = [] total_qty = 0 total_amount = 0 for row in rows: drug_code = row.drug_code or '' product_name = row.product_name or '' # 검색 필터 if search: search_lower = search.lower() if (search_lower not in product_name.lower() and search_lower not in drug_code.lower()): continue qty = int(row.total_qty or 0) amount = float(row.total_amount or 0) items.append({ 'drug_code': drug_code, 'product_name': product_name, 'supplier': row.supplier or '', 'barcode': row.barcode or '', 'total_qty': qty, 'total_amount': int(amount), 'thumbnail': None }) total_qty += qty total_amount += amount # 정렬 if sort == 'qty_asc': items.sort(key=lambda x: x['total_qty']) elif sort == 'qty_desc': items.sort(key=lambda x: x['total_qty'], reverse=True) elif sort == 'name_asc': items.sort(key=lambda x: x['product_name']) elif sort == 'amount_desc': items.sort(key=lambda x: x['total_amount'], reverse=True) # 제품 이미지 조회 try: images_db_path = Path(__file__).parent / 'db' / 'product_images.db' if images_db_path.exists(): img_conn = sqlite3.connect(str(images_db_path)) img_cursor = img_conn.cursor() barcodes = [item['barcode'] for item in items if item['barcode']] drug_codes = [item['drug_code'] for item in items] image_map = {} if barcodes: placeholders = ','.join(['?' for _ in barcodes]) img_cursor.execute(f''' SELECT barcode, thumbnail_base64 FROM product_images WHERE barcode IN ({placeholders}) AND thumbnail_base64 IS NOT NULL ''', barcodes) for r in img_cursor.fetchall(): image_map[f'bc:{r[0]}'] = r[1] if drug_codes: placeholders = ','.join(['?' for _ in drug_codes]) img_cursor.execute(f''' SELECT drug_code, thumbnail_base64 FROM product_images WHERE drug_code IN ({placeholders}) AND thumbnail_base64 IS NOT NULL ''', drug_codes) for r in img_cursor.fetchall(): if f'dc:{r[0]}' not in image_map: image_map[f'dc:{r[0]}'] = r[1] img_conn.close() for item in items: thumb = image_map.get(f'bc:{item["barcode"]}') or image_map.get(f'dc:{item["drug_code"]}') if thumb: item['thumbnail'] = thumb except Exception as img_err: logging.warning(f"제품 이미지 조회 오류: {img_err}") # 기간 일수 계산 try: from datetime import datetime as dt start_dt = dt.strptime(start_date_fmt, '%Y%m%d') end_dt = dt.strptime(end_date_fmt, '%Y%m%d') period_days = (end_dt - start_dt).days + 1 except: period_days = 1 return jsonify({ 'success': True, 'items': items[:500], # 최대 500건 'stats': { 'period_days': period_days, 'product_count': len(items), 'total_qty': total_qty, 'total_amount': int(total_amount) } }) except Exception as e: logging.error(f"사용량 조회 오류: {e}") return jsonify({ 'success': False, 'error': str(e) }), 500 @app.route('/api/rx-usage') def api_rx_usage(): """ 전문의약품(처방전) 기간별 사용량 조회 API GET /api/rx-usage?start_date=2026-01-01&end_date=2026-01-31&search=타이레놀&sort=qty_desc """ try: start_date = request.args.get('start_date', '') end_date = request.args.get('end_date', '') search = request.args.get('search', '').strip() sort = request.args.get('sort', 'qty_desc') # 날짜 형식 변환 (YYYY-MM-DD -> YYYYMMDD) start_date_fmt = start_date.replace('-', '') if start_date else datetime.now().strftime('%Y%m%d') end_date_fmt = end_date.replace('-', '') if end_date else datetime.now().strftime('%Y%m%d') mssql_session = db_manager.get_session('PM_PRES') # 1년간 사용 환자 3명 이하 약품의 환자 목록 조회 + 조회 기간 내 사용 여부 # PS_Type: 0,1=일반, 4=대체조제(실제), 9=대체조제(원본) - 9는 제외해야 실제 조제된 약만 집계 patient_query = text(""" WITH PatientUsage AS ( SELECT DISTINCT P.DrugCode, M.Paname, MAX(CASE WHEN M.Indate >= :start_date AND M.Indate <= :end_date THEN 1 ELSE 0 END) as used_in_period FROM PS_sub_pharm P JOIN PS_main M ON P.PreSerial = M.PreSerial WHERE M.Indate >= CONVERT(VARCHAR, DATEADD(YEAR, -1, GETDATE()), 112) AND (P.PS_Type IS NULL OR P.PS_Type != '9') GROUP BY P.DrugCode, M.Paname ) SELECT PU.DrugCode as drug_code, COUNT(*) as patient_count, STUFF(( SELECT ', ' + PU2.Paname FROM PatientUsage PU2 WHERE PU2.DrugCode = PU.DrugCode ORDER BY PU2.Paname FOR XML PATH(''), TYPE ).value('.', 'NVARCHAR(MAX)'), 1, 2, '') as patient_names, STUFF(( SELECT ', ' + PU3.Paname FROM PatientUsage PU3 WHERE PU3.DrugCode = PU.DrugCode AND PU3.used_in_period = 1 ORDER BY PU3.Paname FOR XML PATH(''), TYPE ).value('.', 'NVARCHAR(MAX)'), 1, 2, '') as today_patients FROM PatientUsage PU GROUP BY PU.DrugCode HAVING COUNT(*) <= 3 """) patient_rows = mssql_session.execute(patient_query, { 'start_date': start_date_fmt, 'end_date': end_date_fmt }).fetchall() patient_map = {row.drug_code: { 'count': row.patient_count, 'names': row.patient_names, 'today': row.today_patients # 오늘 사용한 환자 } for row in patient_rows} # 조회 기간 내 주문량 조회 (orders.db에서) import sqlite3 orders_db_path = os.path.join(os.path.dirname(__file__), 'db', 'orders.db') orders_conn = sqlite3.connect(orders_db_path) orders_cur = orders_conn.cursor() # 조회 기간 내 성공한 주문량 집계 orders_cur.execute(''' SELECT oi.drug_code, SUM(oi.order_qty) as ordered_qty, SUM(oi.total_dose) as ordered_dose FROM order_items oi JOIN orders o ON oi.order_id = o.id WHERE o.order_date >= ? AND o.order_date <= ? AND o.status IN ('submitted', 'success', 'confirmed') AND oi.status IN ('success', 'pending') GROUP BY oi.drug_code ''', (start_date, end_date)) ordered_map = {row[0]: {'qty': row[1] or 0, 'dose': row[2] or 0} for row in orders_cur.fetchall()} orders_conn.close() # 전문의약품 품목별 사용량 집계 쿼리 (현재고: IM_total.IM_QT_sale_debit, 위치: CD_item_position.CD_NM_sale) # PS_Type != '9' 조건: 대체조제 원본 처방 제외 → 실제 조제된 약만 집계 rx_query = text(""" SELECT P.DrugCode as drug_code, ISNULL(G.GoodsName, '알 수 없음') as product_name, ISNULL(G.SplName, '') as supplier, SUM(ISNULL(P.QUAN, 1)) as total_qty, SUM(ISNULL(P.QUAN, 1) * ISNULL(P.Days, 1)) as total_dose, SUM(ISNULL(P.DRUPRICE, 0)) as total_amount, -- DRUPRICE 합계 COUNT(DISTINCT P.PreSerial) as prescription_count, COALESCE(NULLIF(G.BARCODE, ''), '') as barcode, ISNULL(IT.IM_QT_sale_debit, 0) as current_stock, ISNULL(POS.CD_NM_sale, '') as location FROM PS_sub_pharm P LEFT JOIN PM_DRUG.dbo.CD_GOODS G ON P.DrugCode = G.DrugCode LEFT JOIN PM_DRUG.dbo.IM_total IT ON P.DrugCode = IT.DrugCode LEFT JOIN PM_DRUG.dbo.CD_item_position POS ON P.DrugCode = POS.DrugCode WHERE P.Indate >= :start_date AND P.Indate <= :end_date AND (P.PS_Type IS NULL OR P.PS_Type != '9') GROUP BY P.DrugCode, G.GoodsName, G.SplName, G.BARCODE, IT.IM_QT_sale_debit, POS.CD_NM_sale ORDER BY SUM(ISNULL(P.QUAN, 1) * ISNULL(P.Days, 1)) DESC """) rows = mssql_session.execute(rx_query, { 'start_date': start_date_fmt, 'end_date': end_date_fmt }).fetchall() items = [] total_qty = 0 total_dose = 0 total_amount = 0 total_prescriptions = set() for row in rows: drug_code = row.drug_code or '' product_name = row.product_name or '' # 검색 필터 if search: search_lower = search.lower() if (search_lower not in product_name.lower() and search_lower not in drug_code.lower()): continue qty = int(row.total_qty or 0) dose = int(row.total_dose or 0) amount = float(row.total_amount or 0) rx_count = int(row.prescription_count or 0) # 소수 환자 약품인지 확인 (1년간 3명 이하) patient_info = patient_map.get(drug_code) # 조회 기간 내 주문량 order_info = ordered_map.get(drug_code, {'qty': 0, 'dose': 0}) ordered_dose = order_info['dose'] # 잔여 필요량 = 사용량 - 주문량 (음수면 0) remaining_dose = max(0, dose - ordered_dose) items.append({ 'drug_code': drug_code, 'product_name': product_name, 'supplier': row.supplier or '', 'barcode': row.barcode or '', 'total_qty': qty, 'total_dose': dose, # 총 투약량 (수량 x 일수) 'total_amount': int(amount), 'prescription_count': rx_count, 'current_stock': int(row.current_stock or 0), # 현재고 'location': row.location or '', # 약국 내 위치 'thumbnail': None, 'patient_count': patient_info['count'] if patient_info else None, 'patient_names': patient_info['names'] if patient_info else None, 'today_patients': patient_info['today'] if patient_info else None, 'ordered_dose': ordered_dose, # 조회 기간 내 주문량 'remaining_dose': remaining_dose # 잔여 필요량 }) total_qty += qty total_dose += dose total_amount += amount # 정렬 if sort == 'qty_asc': items.sort(key=lambda x: x['total_dose']) elif sort == 'qty_desc': items.sort(key=lambda x: x['total_dose'], reverse=True) elif sort == 'name_asc': items.sort(key=lambda x: x['product_name']) elif sort == 'amount_desc': items.sort(key=lambda x: x['total_amount'], reverse=True) elif sort == 'rx_desc': items.sort(key=lambda x: x['prescription_count'], reverse=True) # 제품 이미지 조회 try: images_db_path = Path(__file__).parent / 'db' / 'product_images.db' if images_db_path.exists(): img_conn = sqlite3.connect(str(images_db_path)) img_cursor = img_conn.cursor() drug_codes = [item['drug_code'] for item in items] image_map = {} if drug_codes: placeholders = ','.join(['?' for _ in drug_codes]) img_cursor.execute(f''' SELECT drug_code, thumbnail_base64 FROM product_images WHERE drug_code IN ({placeholders}) AND thumbnail_base64 IS NOT NULL ''', drug_codes) for r in img_cursor.fetchall(): image_map[r[0]] = r[1] img_conn.close() for item in items: if item['drug_code'] in image_map: item['thumbnail'] = image_map[item['drug_code']] except Exception as img_err: logging.warning(f"제품 이미지 조회 오류: {img_err}") # 기간 일수 계산 try: from datetime import datetime as dt start_dt = dt.strptime(start_date_fmt, '%Y%m%d') end_dt = dt.strptime(end_date_fmt, '%Y%m%d') period_days = (end_dt - start_dt).days + 1 except: period_days = 1 return jsonify({ 'success': True, 'items': items[:500], 'stats': { 'period_days': period_days, 'product_count': len(items), 'total_qty': total_qty, 'total_dose': total_dose, 'total_amount': int(total_amount) } }) except Exception as e: logging.error(f"전문의약품 사용량 조회 오류: {e}") return jsonify({ 'success': False, 'error': str(e) }), 500 # ===== Claude 상태 API ===== @app.route('/api/claude-status') def api_claude_status(): """ Claude 사용량 상태 조회 (토큰 차감 없음) GET /api/claude-status GET /api/claude-status?detail=true — 전체 세션 상세 포함 Returns: { "ok": true, "connected": true, "model": "claude-opus-4-5", "mainSession": { ... }, "summary": { ... }, "sessions": [ ... ], // detail=true 일 때만 "timestamp": "2026-02-27T09:45:00+09:00" } """ try: from services.clawdbot_client import get_claude_status # 상세 모드 여부 detail_mode = request.args.get('detail', 'false').lower() == 'true' status = get_claude_status() if not status or not status.get('connected'): return jsonify({ 'ok': False, 'connected': False, 'error': status.get('error', 'Gateway 연결 실패'), 'timestamp': datetime.now(KST).isoformat() }), 503 sessions_data = status.get('sessions', {}) sessions_list = sessions_data.get('sessions', []) defaults = sessions_data.get('defaults', {}) # 메인 세션 찾기 main_session = None for s in sessions_list: if s.get('key') == 'agent:main:main': main_session = s break # 전체 토큰 합계 total_tokens = sum(s.get('totalTokens', 0) for s in sessions_list) # 메인 세션 컨텍스트 사용률 context_used = 0 context_max = defaults.get('contextTokens', 200000) context_percent = 0 if main_session: context_used = main_session.get('totalTokens', 0) context_max = main_session.get('contextTokens', context_max) if context_max > 0: context_percent = round(context_used / context_max * 100, 1) # 기본 응답 response = { 'ok': True, 'connected': True, 'model': f"{defaults.get('modelProvider', 'unknown')}/{defaults.get('model', 'unknown')}", 'context': { 'used': context_used, 'max': context_max, 'percent': context_percent, 'display': f"{context_used//1000}k/{context_max//1000}k ({context_percent}%)" }, 'mainSession': { 'key': main_session.get('key') if main_session else None, 'inputTokens': main_session.get('inputTokens', 0) if main_session else 0, 'outputTokens': main_session.get('outputTokens', 0) if main_session else 0, 'totalTokens': main_session.get('totalTokens', 0) if main_session else 0, 'lastChannel': main_session.get('lastChannel') if main_session else None } if main_session else None, 'summary': { 'totalSessions': len(sessions_list), 'totalTokens': total_tokens, 'activeModel': defaults.get('model') }, 'timestamp': datetime.now(KST).isoformat() } # 상세 모드: 전체 세션 목록 추가 if detail_mode: detailed_sessions = [] for s in sessions_list: session_tokens = s.get('totalTokens', 0) session_context_max = s.get('contextTokens', 200000) session_percent = round(session_tokens / session_context_max * 100, 1) if session_context_max > 0 else 0 # 세션 키에서 이름 추출 (agent:main:xxx → xxx) session_key = s.get('key', '') session_name = session_key.split(':')[-1] if ':' in session_key else session_key # 마지막 활동 시간 updated_at = s.get('updatedAt') updated_str = None if updated_at: try: dt = datetime.fromtimestamp(updated_at / 1000, tz=KST) updated_str = dt.strftime('%Y-%m-%d %H:%M') except: pass detailed_sessions.append({ 'key': session_key, 'name': session_name, 'displayName': s.get('displayName', session_name), 'model': f"{s.get('modelProvider', 'unknown')}/{s.get('model', 'unknown')}", 'tokens': { 'input': s.get('inputTokens', 0), 'output': s.get('outputTokens', 0), 'total': session_tokens, 'contextMax': session_context_max, 'contextPercent': session_percent, 'display': f"{session_tokens//1000}k/{session_context_max//1000}k ({session_percent}%)" }, 'channel': s.get('lastChannel') or s.get('origin', {}).get('provider'), 'kind': s.get('kind'), 'updatedAt': updated_str }) # 토큰 사용량 순으로 정렬 detailed_sessions.sort(key=lambda x: x['tokens']['total'], reverse=True) response['sessions'] = detailed_sessions # 모델별 통계 model_stats = {} for s in sessions_list: model_key = f"{s.get('modelProvider', 'unknown')}/{s.get('model', 'unknown')}" if model_key not in model_stats: model_stats[model_key] = {'sessions': 0, 'tokens': 0} model_stats[model_key]['sessions'] += 1 model_stats[model_key]['tokens'] += s.get('totalTokens', 0) response['modelStats'] = model_stats return jsonify(response) except Exception as e: logging.error(f"Claude 상태 조회 오류: {e}") return jsonify({ 'ok': False, 'connected': False, 'error': str(e), 'timestamp': datetime.now(KST).isoformat() }), 500 # ============================================================================= # 회원 검색 API (팜IT3000 CD_PERSON) # ============================================================================= @app.route('/api/members/search') def api_members_search(): """ 회원 검색 API - 이름 또는 전화번호로 검색 - PM_BASE.dbo.CD_PERSON 테이블 조회 """ search = request.args.get('q', '').strip() search_type = request.args.get('type', 'auto') # auto, name, phone limit = min(int(request.args.get('limit', 50)), 200) if not search or len(search) < 2: return jsonify({'success': False, 'error': '검색어는 2글자 이상 입력하세요'}) try: # PM_BASE 연결 base_session = db_manager.get_session('PM_BASE') # 검색 타입 자동 감지 if search_type == 'auto': # 숫자만 있으면 전화번호, 아니면 이름 if search.replace('-', '').replace(' ', '').isdigit(): search_type = 'phone' else: search_type = 'name' # 전화번호 정규화 phone_search = search.replace('-', '').replace(' ', '') if search_type == 'phone': # 전화번호 검색 (3개 컬럼 모두) query = text(f""" SELECT TOP {limit} CUSCODE, PANAME, PANUM, TEL_NO, PHONE, PHONE2, CUSETC, EMAIL, SMS_STOP FROM CD_PERSON WHERE REPLACE(REPLACE(TEL_NO, '-', ''), ' ', '') LIKE :phone OR REPLACE(REPLACE(PHONE, '-', ''), ' ', '') LIKE :phone OR REPLACE(REPLACE(PHONE2, '-', ''), ' ', '') LIKE :phone ORDER BY PANAME """) rows = base_session.execute(query, {'phone': f'%{phone_search}%'}).fetchall() else: # 이름 검색 query = text(f""" SELECT TOP {limit} CUSCODE, PANAME, PANUM, TEL_NO, PHONE, PHONE2, CUSETC, EMAIL, SMS_STOP FROM CD_PERSON WHERE PANAME LIKE :name ORDER BY PANAME """) rows = base_session.execute(query, {'name': f'%{search}%'}).fetchall() members = [] for row in rows: # 유효한 전화번호 찾기 (PHONE 우선, 없으면 TEL_NO, PHONE2) phone = row.PHONE or row.TEL_NO or row.PHONE2 or '' phone = phone.strip() if phone else '' members.append({ 'cuscode': row.CUSCODE, 'name': row.PANAME or '', 'panum': row.PANUM or '', 'phone': phone, 'tel_no': row.TEL_NO or '', 'phone1': row.PHONE or '', 'phone2': row.PHONE2 or '', 'memo': (row.CUSETC or '')[:100], # 메모 100자 제한 'email': row.EMAIL or '', 'sms_stop': row.SMS_STOP == 'Y' # SMS 수신거부 여부 }) return jsonify({ 'success': True, 'items': members, 'count': len(members), 'search_type': search_type }) except Exception as e: logging.error(f"회원 검색 오류: {e}") return jsonify({'success': False, 'error': str(e)}), 500 @app.route('/api/members/') def api_member_detail(cuscode): """회원 상세 정보 + 메모 조회""" try: base_session = db_manager.get_session('PM_BASE') # 회원 정보 query = text(""" SELECT CUSCODE, PANAME, PANUM, TEL_NO, PHONE, PHONE2, CUSETC, EMAIL, ADDRESS, SMS_STOP FROM CD_PERSON WHERE CUSCODE = :cuscode """) row = base_session.execute(query, {'cuscode': cuscode}).fetchone() if not row: return jsonify({'success': False, 'error': '회원을 찾을 수 없습니다'}), 404 member = { 'cuscode': row.CUSCODE, 'name': row.PANAME or '', 'panum': row.PANUM or '', 'phone': row.PHONE or row.TEL_NO or row.PHONE2 or '', 'tel_no': row.TEL_NO or '', 'phone1': row.PHONE or '', 'phone2': row.PHONE2 or '', 'memo': row.CUSETC or '', 'email': row.EMAIL or '', 'address': row.ADDRESS or '', 'sms_stop': row.SMS_STOP == 'Y' } # 상세 메모 조회 memo_query = text(""" SELECT MEMO_CODE, PHARMA_ID, MEMO_DATE, MEMO_TITLE, MEMO_Item FROM CD_PERSON_MEMO WHERE CUSCODE = :cuscode ORDER BY MEMO_DATE DESC """) memo_rows = base_session.execute(memo_query, {'cuscode': cuscode}).fetchall() memos = [] for m in memo_rows: memos.append({ 'memo_code': m.MEMO_CODE, 'author': m.PHARMA_ID or '', 'date': m.MEMO_DATE or '', 'title': m.MEMO_TITLE or '', 'content': m.MEMO_Item or '' }) return jsonify({ 'success': True, 'member': member, 'memos': memos }) except Exception as e: logging.error(f"회원 상세 조회 오류: {e}") return jsonify({'success': False, 'error': str(e)}), 500 @app.route('/api/members//cusetc', methods=['PUT']) def api_update_cusetc(cuscode): """특이(참고)사항 수정 API""" try: data = request.get_json() or {} new_cusetc = data.get('cusetc', '').strip() # 길이 제한 (2000자) if len(new_cusetc) > 2000: return jsonify({'success': False, 'error': '특이사항은 2000자를 초과할 수 없습니다.'}), 400 base_session = db_manager.get_session('PM_BASE') # CUSETC 업데이트 update_query = text(""" UPDATE CD_PERSON SET CUSETC = :cusetc WHERE CUSCODE = :cuscode """) result = base_session.execute(update_query, {'cusetc': new_cusetc, 'cuscode': cuscode}) base_session.commit() if result.rowcount == 0: return jsonify({'success': False, 'error': '해당 고객을 찾을 수 없습니다.'}), 404 return jsonify({ 'success': True, 'message': '특이사항이 저장되었습니다.', 'cusetc': new_cusetc }) except Exception as e: logging.error(f"특이사항 수정 오류: {e}") return jsonify({'success': False, 'error': str(e)}), 500 @app.route('/api/print/cusetc', methods=['POST']) def api_print_cusetc(): """특이(참고)사항 영수증 인쇄 API""" try: from pos_printer import print_cusetc data = request.get_json() or {} customer_name = data.get('customer_name', '').strip() cusetc = data.get('cusetc', '').strip() phone = data.get('phone', '').strip() if not customer_name: return jsonify({'success': False, 'error': '고객 이름이 필요합니다.'}), 400 if not cusetc: return jsonify({'success': False, 'error': '특이사항이 비어있습니다.'}), 400 # ESC/POS 프린터로 출력 result = print_cusetc(customer_name, cusetc, phone) if result: return jsonify({ 'success': True, 'message': f'{customer_name}님의 특이사항이 인쇄되었습니다.' }) else: return jsonify({ 'success': False, 'error': '프린터 연결 실패. 프린터가 켜져있는지 확인하세요.' }), 500 except ImportError: return jsonify({'success': False, 'error': 'pos_printer 모듈을 찾을 수 없습니다.'}), 500 except Exception as e: logging.error(f"특이사항 인쇄 오류: {e}") return jsonify({'success': False, 'error': str(e)}), 500 @app.route('/api/members/history/') def api_member_history(phone): """ 회원 구매 이력 통합 조회 API - 마일리지 적립/사용 내역 (SQLite) - transaction_id로 POS 품목 조회 (MSSQL) - 전체 구매 이력 (SALE_MAIN) - 조제 이력 (PS_main) """ try: # 전화번호 정규화 phone = phone.replace('-', '').replace(' ', '') result = { 'success': True, 'phone': phone, 'mileage': None, 'purchases': [], # 전체 구매 이력 'prescriptions': [], # 조제 이력 'interests': [], # 관심 상품 (AI 업셀링) 'pos_customer': None } transaction_ids = [] # 적립된 거래번호 수집 # 1. 마일리지 내역 조회 (SQLite) - 새 연결 사용 (멀티스레드 안전) sqlite_conn = None try: sqlite_conn = db_manager.get_sqlite_connection(new_connection=True) 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'] # 적립/사용 내역 조회 (claim_tokens 조인으로 금액 포함) cursor.execute(""" SELECT ml.points, ml.balance_after, ml.reason, ml.description, ml.transaction_id, ml.created_at, ct.total_amount FROM mileage_ledger ml LEFT JOIN claim_tokens ct ON ml.transaction_id = ct.transaction_id WHERE ml.user_id = ? ORDER BY ml.created_at DESC LIMIT 50 """, (user_id,)) transactions = cursor.fetchall() tx_list = [] for t in transactions: tx_data = { 'points': t['points'], 'balance_after': t['balance_after'], 'reason': t['reason'], 'description': t['description'], 'transaction_id': t['transaction_id'], 'created_at': t['created_at'], 'total_amount': t['total_amount'], 'items': [] # 나중에 POS에서 채움 } tx_list.append(tx_data) # 적립 거래번호 수집 if t['transaction_id'] and t['reason'] == 'CLAIM': transaction_ids.append(t['transaction_id']) 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': tx_list } # 관심 상품 조회 (AI 업셀링에서 '관심있어요' 표시한 것) cursor.execute(""" SELECT id, recommended_product, recommendation_message, recommendation_reason, trigger_products, created_at FROM ai_recommendations WHERE user_id = ? AND status = 'interested' ORDER BY created_at DESC LIMIT 20 """, (user_id,)) interests = cursor.fetchall() result['interests'] = [{ 'id': i['id'], 'product': i['recommended_product'], 'message': i['recommendation_message'], 'reason': i['recommendation_reason'], 'trigger_products': i['trigger_products'], 'created_at': i['created_at'] } for i in interests] except Exception as e: import traceback logging.error(f"마일리지 조회 실패: {e}\n{traceback.format_exc()}") finally: # SQLite 연결 닫기 if sqlite_conn: try: sqlite_conn.close() except: pass # 2. 전화번호로 POS 고객코드 조회 (MSSQL) cuscode = None try: base_session = db_manager.get_session('PM_BASE') 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 } except Exception as e: logging.warning(f"POS 고객 조회 실패: {e}") # 3. transaction_id로 POS 품목 조회 (마일리지 적립 내역) if transaction_ids and result['mileage']: try: pres_session = db_manager.get_session('PM_PRES') # 각 거래번호별 품목 조회 items_by_tx = {} for tx_id in transaction_ids[:30]: # 최대 30건 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': tx_id}).fetchall() items_by_tx[tx_id] = [{ '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] # 마일리지 내역에 품목 추가 for tx in result['mileage']['transactions']: tx_id = tx.get('transaction_id') if tx_id and tx_id in items_by_tx: tx['items'] = items_by_tx[tx_id] except Exception as e: logging.warning(f"POS 품목 조회 실패: {e}") # 4. 전체 구매 이력 조회 (고객코드 기준) if cuscode: try: pres_session = db_manager.get_session('PM_PRES') # 최근 60일 구매 이력 purchase_query = text(""" SELECT TOP 30 M.SL_NO_order as order_no, M.SL_DT_appl as order_date, M.SL_MY_total as total_amount FROM SALE_MAIN M WHERE M.SL_CD_custom = :cuscode 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: # 품목 조회 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, '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"전체 구매 이력 조회 실패: {e}") # 5. 조제 이력 조회 (고객코드 기준) if cuscode: try: pres_session = db_manager.get_session('PM_PRES') # 최근 조제 이력 (최대 30건) rx_query = text(""" SELECT TOP 30 P.PreSerial, P.Indate, P.Paname, P.Drname, P.OrderName, P.TDAYS FROM PS_main P WHERE P.CusCode = :cuscode ORDER BY P.Indate DESC, P.PreSerial DESC """) rxs = pres_session.execute(rx_query, {'cuscode': cuscode}).fetchall() prescriptions = [] for rx in rxs: # 처방 품목 조회 items_query = text(""" SELECT S.DrugCode, G.GoodsName, S.Days, S.QUAN, S.QUAN_TIME FROM PS_sub_pharm S LEFT JOIN PM_DRUG.dbo.CD_GOODS G ON S.DrugCode = G.DrugCode WHERE S.PreSerial = :pre_serial """) items = pres_session.execute(items_query, {'pre_serial': rx.PreSerial}).fetchall() prescriptions.append({ 'pre_serial': rx.PreSerial, 'date': rx.Indate, 'patient_name': rx.Paname, 'doctor': rx.Drname, 'hospital': rx.OrderName, 'total_days': rx.TDAYS, 'items': [{ 'drug_code': item.DrugCode, 'name': item.GoodsName or '알 수 없음', 'days': float(item.Days) if item.Days else 0, 'quantity': float(item.QUAN) if item.QUAN else 0, 'times_per_day': float(item.QUAN_TIME) if item.QUAN_TIME else 0 } for item in items] }) result['prescriptions'] = prescriptions except Exception as e: logging.warning(f"조제 이력 조회 실패: {e}") return jsonify(result) except Exception as e: logging.error(f"회원 이력 조회 오류: {e}") return jsonify({'success': False, 'error': str(e)}), 500 # ============================================================================= # 알림톡/SMS 발송 API # ============================================================================= @app.route('/api/message/send', methods=['POST']) def api_message_send(): """ 알림톡/SMS 발송 API Body: { "recipients": [{"cuscode": "", "name": "", "phone": ""}], "message": "메시지 내용", "type": "alimtalk" | "sms" } """ try: data = request.get_json() if not data: return jsonify({'success': False, 'error': '데이터가 없습니다'}), 400 recipients = data.get('recipients', []) message = data.get('message', '') msg_type = data.get('type', 'sms') # alimtalk 또는 sms if not recipients: return jsonify({'success': False, 'error': '수신자가 없습니다'}), 400 if not message: return jsonify({'success': False, 'error': '메시지 내용이 없습니다'}), 400 # 전화번호 정규화 valid_recipients = [] for r in recipients: phone = (r.get('phone') or '').replace('-', '').replace(' ', '') if phone and len(phone) >= 10: valid_recipients.append({ 'cuscode': r.get('cuscode', ''), 'name': r.get('name', ''), 'phone': phone }) if not valid_recipients: return jsonify({'success': False, 'error': '유효한 전화번호가 없습니다'}), 400 # TODO: 실제 발송 로직 (NHN Cloud 알림톡/SMS) # 현재는 테스트 모드로 성공 응답 results = [] for r in valid_recipients: results.append({ 'phone': r['phone'], 'name': r['name'], 'status': 'success', # 실제 발송 시 결과로 변경 'message': f'{msg_type} 발송 예약됨' }) return jsonify({ 'success': True, 'type': msg_type, 'sent_count': len(results), 'results': results, 'message': f'{len(results)}명에게 {msg_type} 발송 완료 (테스트 모드)' }) except Exception as e: logging.error(f"메시지 발송 오류: {e}") return jsonify({'success': False, 'error': str(e)}), 500 # ============================================================================= # QR 라벨 인쇄 API # ============================================================================= @app.route('/api/qr-print', methods=['POST']) def api_qr_print(): """QR 라벨 인쇄 API""" try: from qr_printer import print_drug_qr_label data = request.get_json() if not data: return jsonify({'success': False, 'error': '데이터가 없습니다'}), 400 drug_name = data.get('drug_name', '') barcode = data.get('barcode', '') drug_code = data.get('drug_code', '') sale_price = data.get('sale_price', 0) if not drug_name: return jsonify({'success': False, 'error': '상품명이 필요합니다'}), 400 # 바코드가 없으면 drug_code 사용 qr_data = barcode if barcode else drug_code result = print_drug_qr_label( drug_name=drug_name, barcode=qr_data, sale_price=sale_price, drug_code=drug_code, pharmacy_name='청춘약국' ) return jsonify(result) except ImportError as e: logging.error(f"QR 프린터 모듈 로드 실패: {e}") return jsonify({'success': False, 'error': 'QR 프린터 모듈이 없습니다'}), 500 except Exception as e: logging.error(f"QR 인쇄 오류: {e}") return jsonify({'success': False, 'error': str(e)}), 500 @app.route('/api/qr-preview', methods=['POST']) def api_qr_preview(): """QR 라벨 미리보기 API (base64 이미지 반환)""" try: from qr_printer import preview_qr_label data = request.get_json() if not data: return jsonify({'success': False, 'error': '데이터가 없습니다'}), 400 drug_name = data.get('drug_name', '') barcode = data.get('barcode', '') drug_code = data.get('drug_code', '') sale_price = data.get('sale_price', 0) if not drug_name: return jsonify({'success': False, 'error': '상품명이 필요합니다'}), 400 qr_data = barcode if barcode else drug_code image_data = preview_qr_label( drug_name=drug_name, barcode=qr_data, sale_price=sale_price, drug_code=drug_code, pharmacy_name='청춘약국' ) return jsonify({'success': True, 'image': image_data}) except Exception as e: logging.error(f"QR 미리보기 오류: {e}") return jsonify({'success': False, 'error': str(e)}), 500 def check_port_available(port: int) -> bool: """포트가 사용 가능한지 확인""" import socket sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM) sock.settimeout(1) result = sock.connect_ex(('127.0.0.1', port)) sock.close() return result != 0 # 0이면 이미 사용 중, 0이 아니면 사용 가능 def kill_process_on_port(port: int) -> bool: """특정 포트를 사용하는 프로세스 종료 (Windows)""" import subprocess try: # netstat으로 PID 찾기 result = subprocess.run( f'netstat -ano | findstr ":{port}"', shell=True, capture_output=True, text=True ) for line in result.stdout.strip().split('\n'): if 'LISTENING' in line: parts = line.split() pid = parts[-1] if pid.isdigit(): subprocess.run(f'taskkill /F /PID {pid}', shell=True) logging.info(f"포트 {port} 사용 중인 프로세스 종료: PID {pid}") return True return False except Exception as e: logging.error(f"프로세스 종료 실패: {e}") return False # ═══════════════════════════════════════════════════════════ # KIMS 약물 상호작용 API # ═══════════════════════════════════════════════════════════ @app.route('/admin/kims-logs') def admin_kims_logs(): """KIMS 상호작용 로그 뷰어 페이지""" return render_template('admin_kims_logs.html') @app.route('/api/kims/logs') def api_kims_logs(): """KIMS 로그 목록 조회""" from db.kims_logger import get_recent_logs limit = int(request.args.get('limit', 100)) status = request.args.get('status', '') interaction = request.args.get('interaction', '') date = request.args.get('date', '') try: logs = get_recent_logs(limit=limit) # 필터링 if status: logs = [l for l in logs if l['api_status'] == status] if interaction == 'has': logs = [l for l in logs if l['interaction_count'] > 0] elif interaction == 'severe': logs = [l for l in logs if l['has_severe_interaction'] == 1] elif interaction == 'none': logs = [l for l in logs if l['interaction_count'] == 0] if date: logs = [l for l in logs if l['created_at'] and l['created_at'].startswith(date)] return jsonify({'success': True, 'logs': logs}) except Exception as e: return jsonify({'success': False, 'error': str(e)}) @app.route('/api/kims/logs/stats') def api_kims_logs_stats(): """KIMS 로그 통계""" from db.kims_logger import get_stats try: stats = get_stats() return jsonify({'success': True, 'stats': stats}) except Exception as e: return jsonify({'success': False, 'error': str(e)}) @app.route('/api/kims/logs/') def api_kims_log_detail(log_id): """KIMS 로그 상세 조회""" from db.kims_logger import get_log_detail try: log = get_log_detail(log_id) if log: return jsonify({'success': True, 'log': log}) else: return jsonify({'success': False, 'error': '로그를 찾을 수 없습니다'}), 404 except Exception as e: return jsonify({'success': False, 'error': str(e)}) # ───────────────────────────────────────────────────────────── # 반품 후보 관리 # ───────────────────────────────────────────────────────────── @app.route('/admin/return-management') def admin_return_management(): """반품 후보 관리 페이지""" return render_template('admin_return_management.html') @app.route('/api/return-candidates') def api_return_candidates(): """반품 후보 목록 조회 API (가격 정보 포함)""" import pyodbc status = request.args.get('status', '') urgency = request.args.get('urgency', '') search = request.args.get('search', '') sort = request.args.get('sort', 'months_desc') try: db_path = os.path.join(BACKEND_DIR, 'db', 'orders.db') conn = sqlite3.connect(db_path) conn.row_factory = sqlite3.Row cursor = conn.cursor() # 기본 쿼리 query = "SELECT * FROM return_candidates WHERE 1=1" params = [] # 상태 필터 if status: query += " AND status = ?" params.append(status) # 검색어 필터 if search: query += " AND (drug_name LIKE ? OR drug_code LIKE ?)" params.extend([f'%{search}%', f'%{search}%']) # 긴급도 필터 if urgency == 'critical': query += " AND (months_since_use >= 36 OR months_since_purchase >= 36)" elif urgency == 'warning': query += " AND ((months_since_use >= 24 OR months_since_purchase >= 24) AND (COALESCE(months_since_use, 0) < 36 AND COALESCE(months_since_purchase, 0) < 36))" elif urgency == 'normal': query += " AND (COALESCE(months_since_use, 0) < 24 AND COALESCE(months_since_purchase, 0) < 24)" # 정렬 sort_map = { 'months_desc': 'COALESCE(months_since_use, months_since_purchase, 0) DESC', 'months_asc': 'COALESCE(months_since_use, months_since_purchase, 0) ASC', 'stock_desc': 'current_stock DESC', 'name_asc': 'drug_name ASC', 'detected_desc': 'detected_at DESC' } query += f" ORDER BY {sort_map.get(sort, 'detected_at DESC')}" cursor.execute(query, params) rows = cursor.fetchall() # 약품코드 목록 추출 drug_codes = [row['drug_code'] for row in rows] # MSSQL에서 가격 + 위치 정보 조회 (한 번에) price_map = {} location_map = {} if drug_codes: try: mssql_conn_str = ( 'DRIVER={ODBC Driver 17 for SQL Server};' 'SERVER=192.168.0.4\\PM2014;' 'DATABASE=PM_DRUG;' 'UID=sa;' 'PWD=tmddls214!%(;' 'TrustServerCertificate=yes;' 'Connection Timeout=5' ) mssql_conn = pyodbc.connect(mssql_conn_str, timeout=5) mssql_cursor = mssql_conn.cursor() # IN 절 생성 (SQL Injection 방지를 위해 파라미터화) # Price가 없으면 Saleprice, Topprice 순으로 fallback # CD_item_position JOIN으로 위치 정보도 함께 조회 placeholders = ','.join(['?' for _ in drug_codes]) mssql_cursor.execute(f""" SELECT G.DrugCode, COALESCE(NULLIF(G.Price, 0), NULLIF(G.Saleprice, 0), NULLIF(G.Topprice, 0), 0) as BestPrice, ISNULL(POS.CD_NM_sale, '') as Location FROM CD_GOODS G LEFT JOIN CD_item_position POS ON G.DrugCode = POS.DrugCode WHERE G.DrugCode IN ({placeholders}) """, drug_codes) for row in mssql_cursor.fetchall(): price_map[row[0]] = float(row[1]) if row[1] else 0 location_map[row[0]] = row[2] or '' mssql_conn.close() except Exception as e: logging.warning(f"MSSQL 가격/위치 조회 실패: {e}") # 전체 데이터 조회 (통계용) cursor.execute("SELECT drug_code, current_stock, months_since_use, months_since_purchase FROM return_candidates") all_rows = cursor.fetchall() # 긴급도별 금액 합계 계산 total_amount = 0 critical_amount = 0 warning_amount = 0 for row in all_rows: code = row['drug_code'] stock = row['current_stock'] or 0 price = price_map.get(code, 0) amount = stock * price months_use = row['months_since_use'] or 0 months_purchase = row['months_since_purchase'] or 0 max_months = max(months_use, months_purchase) total_amount += amount if max_months >= 36: critical_amount += amount elif max_months >= 24: warning_amount += amount items = [] for row in rows: code = row['drug_code'] stock = row['current_stock'] or 0 price = price_map.get(code, 0) recoverable = stock * price items.append({ 'id': row['id'], 'drug_code': code, 'drug_name': row['drug_name'], 'current_stock': stock, 'unit_price': price, 'recoverable_amount': recoverable, 'location': location_map.get(code, ''), 'last_prescription_date': row['last_prescription_date'], 'months_since_use': row['months_since_use'], 'last_purchase_date': row['last_purchase_date'], 'months_since_purchase': row['months_since_purchase'], 'status': row['status'], 'decision_reason': row['decision_reason'], 'detected_at': row['detected_at'], 'updated_at': row['updated_at'] }) # 통계 계산 cursor.execute("SELECT COUNT(*) FROM return_candidates WHERE months_since_use >= 36 OR months_since_purchase >= 36") critical = cursor.fetchone()[0] cursor.execute("""SELECT COUNT(*) FROM return_candidates WHERE (months_since_use >= 24 OR months_since_purchase >= 24) AND (COALESCE(months_since_use, 0) < 36 AND COALESCE(months_since_purchase, 0) < 36)""") warning = cursor.fetchone()[0] cursor.execute("SELECT COUNT(*) FROM return_candidates WHERE status = 'pending'") pending = cursor.fetchone()[0] cursor.execute("SELECT COUNT(*) FROM return_candidates WHERE status IN ('returned', 'keep', 'disposed', 'resolved')") processed = cursor.fetchone()[0] cursor.execute("SELECT COUNT(*) FROM return_candidates") total = cursor.fetchone()[0] conn.close() return jsonify({ 'success': True, 'items': items, 'stats': { 'critical': critical, 'warning': warning, 'pending': pending, 'processed': processed, 'total': total, 'total_amount': total_amount, 'critical_amount': critical_amount, 'warning_amount': warning_amount } }) except Exception as e: return jsonify({'success': False, 'error': str(e)}), 500 @app.route('/api/return-candidates/', methods=['PUT']) def api_update_return_candidate(item_id): """반품 후보 상태 변경 API""" try: data = request.get_json() new_status = data.get('status') reason = data.get('reason', '') if not new_status: return jsonify({'success': False, 'error': '상태가 지정되지 않았습니다'}), 400 valid_statuses = ['pending', 'reviewed', 'returned', 'keep', 'disposed', 'resolved'] if new_status not in valid_statuses: return jsonify({'success': False, 'error': f'유효하지 않은 상태: {new_status}'}), 400 db_path = os.path.join(BACKEND_DIR, 'db', 'orders.db') conn = sqlite3.connect(db_path) cursor = conn.cursor() cursor.execute(""" UPDATE return_candidates SET status = ?, decision_reason = ?, reviewed_at = datetime('now', 'localtime'), updated_at = datetime('now', 'localtime') WHERE id = ? """, (new_status, reason, item_id)) if cursor.rowcount == 0: conn.close() return jsonify({'success': False, 'error': '항목을 찾을 수 없습니다'}), 404 conn.commit() conn.close() return jsonify({'success': True, 'message': '상태가 변경되었습니다'}) except Exception as e: return jsonify({'success': False, 'error': str(e)}), 500 @app.route('/api/kims/interaction-check', methods=['POST']) def api_kims_interaction_check(): """ KIMS 약물 상호작용 체크 API Request: { "drug_codes": ["055101150", "622801610"], // DrugCode 배열 "pre_serial": "P20250630001" // 처방번호 (로깅용, optional) } Response: { "success": true, "interactions": [...], "safe_count": 2, "drugs_checked": [{"code": "...", "name": "...", "kd_code": "..."}] } """ import requests as http_requests from db.kims_logger import log_kims_call import time as time_module start_time = time_module.time() try: data = request.get_json() drug_codes = data.get('drug_codes', []) pre_serial = data.get('pre_serial', '') user_id = data.get('user_id') # 회원 ID (있으면) if len(drug_codes) < 2: return jsonify({ 'success': False, 'error': '상호작용 체크를 위해 최소 2개 이상의 약품이 필요합니다.' }), 400 # 1. DrugCode = KIMS KD코드 (9자리) - 직접 사용 drug_session = db_manager.get_session('PM_DRUG') placeholders = ','.join([f"'{c}'" for c in drug_codes]) code_query = text(f""" SELECT DrugCode, GoodsName FROM CD_GOODS WHERE DrugCode IN ({placeholders}) """) code_result = drug_session.execute(code_query).fetchall() # DrugCode를 KIMS KD코드로 직접 사용 kd_codes = [] drugs_info = [] for row in code_result: kd_code = row.DrugCode # DrugCode 자체가 KIMS 코드 if kd_code and len(str(kd_code)) == 9: kd_codes.append(str(kd_code)) drugs_info.append({ 'drug_code': row.DrugCode, 'name': row.GoodsName[:50] if row.GoodsName else '알 수 없음', 'kd_code': str(kd_code) }) if len(kd_codes) < 2: return jsonify({ 'success': False, 'error': 'KIMS 코드로 변환 가능한 약품이 2개 미만입니다.', 'drugs_checked': drugs_info }), 400 # 2. KIMS API 호출 kims_url = "https://api2.kims.co.kr/api/interaction/info" kims_headers = { 'Authorization': 'Basic VFNQTUtSOg==', 'Content-Type': 'application/json', 'Accept': 'application/json; charset=utf-8' } kims_payload = {'KDCodes': kd_codes} try: kims_response = http_requests.get( kims_url, headers=kims_headers, data=json.dumps(kims_payload), timeout=10, verify=False # SSL 검증 비활성화 (프로덕션에서는 주의) ) if kims_response.status_code != 200: log_kims_call(pre_serial=pre_serial, drug_codes=kd_codes, drug_names=[d['name'] for d in drugs_info], api_status='ERROR', http_status=kims_response.status_code, response_time_ms=int((time_module.time() - start_time) * 1000), error_message=f'HTTP {kims_response.status_code}') return jsonify({ 'success': False, 'error': f'KIMS API 응답 오류: HTTP {kims_response.status_code}', 'drugs_checked': drugs_info }), 502 kims_data = kims_response.json() if kims_data.get('Message') != 'SUCCESS': log_kims_call(pre_serial=pre_serial, drug_codes=kd_codes, drug_names=[d['name'] for d in drugs_info], api_status='ERROR', http_status=200, response_time_ms=int((time_module.time() - start_time) * 1000), error_message=f'KIMS: {kims_data.get("Message")}', response_raw=kims_data) return jsonify({ 'success': False, 'error': f'KIMS API 처리 실패: {kims_data.get("Message", "알 수 없는 오류")}', 'drugs_checked': drugs_info }), 502 except http_requests.Timeout: log_kims_call(pre_serial=pre_serial, drug_codes=kd_codes, drug_names=[d['name'] for d in drugs_info], api_status='TIMEOUT', response_time_ms=10000, error_message='10초 초과') return jsonify({ 'success': False, 'error': 'KIMS API 타임아웃 (10초 초과)', 'drugs_checked': drugs_info }), 504 except Exception as kims_err: log_kims_call(pre_serial=pre_serial, drug_codes=kd_codes, drug_names=[d['name'] for d in drugs_info], api_status='ERROR', response_time_ms=int((time_module.time() - start_time) * 1000), error_message=str(kims_err)) logging.error(f"KIMS API 호출 실패: {kims_err}") return jsonify({ 'success': False, 'error': f'KIMS API 연결 실패: {str(kims_err)}', 'drugs_checked': drugs_info }), 502 # 3. 상호작용 결과 파싱 interactions = [] severity_color = {'1': '#dc2626', '2': '#f59e0b', '3': '#3b82f6', '4': '#6b7280', '5': '#9ca3af'} # 상호작용 있는 약품 코드 수집 interaction_drug_codes = set() for alert in kims_data.get('AlertList', []): for item in alert.get('AlertInfo', []): severity = str(item.get('SeverityLevel', '5')) severity_desc = item.get('SeverityDesc', '') # API에서 직접 제공 (중증, 경미 등) # 상호작용 약품 코드 수집 if item.get('DrugCode1'): interaction_drug_codes.add(item.get('DrugCode1')) if item.get('DrugCode2'): interaction_drug_codes.add(item.get('DrugCode2')) interactions.append({ 'drug1_code': item.get('DrugCode1'), 'drug1_name': item.get('ProductName1'), 'drug2_code': item.get('DrugCode2'), 'drug2_name': item.get('ProductName2'), 'generic1': item.get('GenericName1'), 'generic2': item.get('GenericName2'), 'severity': severity, 'severity_text': severity_desc or ('심각' if severity == '1' else '중등도' if severity == '2' else '경미' if severity == '3' else '참고'), 'severity_color': severity_color.get(severity, '#9ca3af'), 'description': item.get('Observation', ''), 'management': item.get('ClinicalMng', ''), 'action': item.get('ActionToTake', ''), 'likelihood': item.get('LikelihoodDesc', '') }) # 심각도 순 정렬 (1=심각이 먼저) interactions.sort(key=lambda x: x['severity']) # 총 약품 쌍 수 계산 total_pairs = len(kd_codes) * (len(kd_codes) - 1) // 2 safe_count = total_pairs - len(interactions) # 약품 목록에 상호작용 여부 표시 for drug in drugs_info: drug['has_interaction'] = drug['kd_code'] in interaction_drug_codes # 응답 시간 계산 response_time_ms = int((time_module.time() - start_time) * 1000) # SQLite 로깅 try: log_id = log_kims_call( pre_serial=pre_serial, user_id=user_id, source='admin', drug_codes=kd_codes, drug_names=[d['name'] for d in drugs_info], api_status='SUCCESS', http_status=200, response_time_ms=response_time_ms, interactions=interactions, response_raw=kims_data ) logging.info(f"KIMS 로그 저장: ID={log_id}, {len(kd_codes)}개 약품, {len(interactions)}건 상호작용") except Exception as log_err: logging.warning(f"KIMS 로깅 실패 (무시): {log_err}") logging.info(f"KIMS 상호작용 체크 완료: {len(kd_codes)}개 약품, {len(interactions)}건 발견 (처방: {pre_serial})") return jsonify({ 'success': True, 'interactions': interactions, 'interaction_count': len(interactions), 'safe_count': max(0, safe_count), 'total_pairs': total_pairs, 'drugs_checked': drugs_info, 'interaction_drug_codes': list(interaction_drug_codes) # 상호작용 있는 약품 코드 }) except Exception as e: # 에러 로깅 response_time_ms = int((time_module.time() - start_time) * 1000) try: log_kims_call( pre_serial=pre_serial if 'pre_serial' in dir() else None, source='admin', drug_codes=drug_codes if 'drug_codes' in dir() else [], api_status='ERROR', response_time_ms=response_time_ms, error_message=str(e) ) except: pass logging.error(f"KIMS 상호작용 체크 오류: {e}") return jsonify({ 'success': False, 'error': f'서버 오류: {str(e)}' }), 500 # ============================================================================== # 반려동물 API # ============================================================================== # 견종/묘종 데이터 DOG_BREEDS = [ '말티즈', '푸들', '포메라니안', '치와와', '시츄', '요크셔테리어', '비숑프리제', '골든리트리버', '래브라도리트리버', '진돗개', '시바견', '웰시코기', '닥스훈트', '비글', '보더콜리', '프렌치불독', '불독', '슈나우저', '사모예드', '허스키', '믹스견', '기타' ] CAT_BREEDS = [ '코리안숏헤어', '페르시안', '러시안블루', '샴', '먼치킨', '랙돌', '브리티쉬숏헤어', '아메리칸숏헤어', '스코티쉬폴드', '노르웨이숲', '메인쿤', '뱅갈', '아비시니안', '터키쉬앙고라', '믹스묘', '기타' ] @app.route('/api/pets', methods=['GET']) def get_pets(): """사용자의 반려동물 목록 조회""" # 세션에서 로그인 유저 확인 user_id = session.get('logged_in_user_id') if not user_id: return jsonify({'success': False, 'error': '로그인이 필요합니다.'}), 401 try: conn = db_manager.get_sqlite_connection() cursor = conn.cursor() cursor.execute(""" SELECT id, name, species, breed, gender, birth_date, age_months, weight, photo_url, notes, created_at FROM pets WHERE user_id = ? AND is_active = TRUE ORDER BY created_at DESC """, (user_id,)) pets = [] for row in cursor.fetchall(): pets.append({ 'id': row['id'], 'name': row['name'], 'species': row['species'], 'species_label': '강아지 🐕' if row['species'] == 'dog' else ('고양이 🐈' if row['species'] == 'cat' else '기타'), 'breed': row['breed'], 'gender': row['gender'], 'birth_date': row['birth_date'], 'age_months': row['age_months'], 'weight': float(row['weight']) if row['weight'] else None, 'photo_url': row['photo_url'], 'notes': row['notes'], 'created_at': utc_to_kst_str(row['created_at']) }) return jsonify({'success': True, 'pets': pets, 'count': len(pets)}) except Exception as e: logging.error(f"반려동물 조회 오류: {e}") return jsonify({'success': False, 'error': str(e)}), 500 @app.route('/api/pets', methods=['POST']) def create_pet(): """반려동물 등록""" user_id = session.get('logged_in_user_id') if not user_id: return jsonify({'success': False, 'error': '로그인이 필요합니다.'}), 401 try: data = request.get_json() name = data.get('name', '').strip() species = data.get('species', '').strip() # dog, cat, other breed = data.get('breed', '').strip() gender = data.get('gender') # male, female, unknown if not name: return jsonify({'success': False, 'error': '이름을 입력해주세요.'}), 400 if species not in ['dog', 'cat', 'other']: return jsonify({'success': False, 'error': '종류를 선택해주세요.'}), 400 conn = db_manager.get_sqlite_connection() cursor = conn.cursor() cursor.execute(""" INSERT INTO pets (user_id, name, species, breed, gender) VALUES (?, ?, ?, ?, ?) """, (user_id, name, species, breed, gender)) pet_id = cursor.lastrowid conn.commit() logging.info(f"반려동물 등록: user_id={user_id}, pet_id={pet_id}, name={name}, species={species}") return jsonify({ 'success': True, 'pet_id': pet_id, 'message': f'{name}이(가) 등록되었습니다!' }) except Exception as e: logging.error(f"반려동물 등록 오류: {e}") return jsonify({'success': False, 'error': str(e)}), 500 @app.route('/api/pets/', methods=['PUT']) def update_pet(pet_id): """반려동물 정보 수정""" user_id = session.get('logged_in_user_id') if not user_id: return jsonify({'success': False, 'error': '로그인이 필요합니다.'}), 401 try: data = request.get_json() conn = db_manager.get_sqlite_connection() cursor = conn.cursor() # 소유권 확인 cursor.execute("SELECT id FROM pets WHERE id = ? AND user_id = ?", (pet_id, user_id)) if not cursor.fetchone(): return jsonify({'success': False, 'error': '반려동물을 찾을 수 없습니다.'}), 404 # 업데이트 필드 구성 updates = [] params = [] if 'name' in data: updates.append("name = ?") params.append(data['name'].strip()) if 'species' in data: updates.append("species = ?") params.append(data['species']) if 'breed' in data: updates.append("breed = ?") params.append(data['breed']) if 'gender' in data: updates.append("gender = ?") params.append(data['gender']) if 'birth_date' in data: updates.append("birth_date = ?") params.append(data['birth_date']) if 'age_months' in data: updates.append("age_months = ?") params.append(data['age_months']) if 'weight' in data: updates.append("weight = ?") params.append(data['weight']) if 'notes' in data: updates.append("notes = ?") params.append(data['notes']) if updates: updates.append("updated_at = CURRENT_TIMESTAMP") params.append(pet_id) cursor.execute(f""" UPDATE pets SET {', '.join(updates)} WHERE id = ? """, params) conn.commit() return jsonify({'success': True, 'message': '수정되었습니다.'}) except Exception as e: logging.error(f"반려동물 수정 오류: {e}") return jsonify({'success': False, 'error': str(e)}), 500 @app.route('/api/pets/', methods=['DELETE']) def delete_pet(pet_id): """반려동물 삭제 (soft delete)""" user_id = session.get('logged_in_user_id') if not user_id: return jsonify({'success': False, 'error': '로그인이 필요합니다.'}), 401 try: conn = db_manager.get_sqlite_connection() cursor = conn.cursor() # 소유권 확인 및 삭제 cursor.execute(""" UPDATE pets SET is_active = FALSE, updated_at = CURRENT_TIMESTAMP WHERE id = ? AND user_id = ? """, (pet_id, user_id)) if cursor.rowcount == 0: return jsonify({'success': False, 'error': '반려동물을 찾을 수 없습니다.'}), 404 conn.commit() return jsonify({'success': True, 'message': '삭제되었습니다.'}) except Exception as e: logging.error(f"반려동물 삭제 오류: {e}") return jsonify({'success': False, 'error': str(e)}), 500 @app.route('/api/pets//photo', methods=['POST']) def upload_pet_photo(pet_id): """반려동물 사진 업로드""" user_id = session.get('logged_in_user_id') if not user_id: return jsonify({'success': False, 'error': '로그인이 필요합니다.'}), 401 try: conn = db_manager.get_sqlite_connection() cursor = conn.cursor() # 소유권 확인 cursor.execute("SELECT id, name FROM pets WHERE id = ? AND user_id = ?", (pet_id, user_id)) pet = cursor.fetchone() if not pet: return jsonify({'success': False, 'error': '반려동물을 찾을 수 없습니다.'}), 404 if 'photo' not in request.files: return jsonify({'success': False, 'error': '사진 파일이 없습니다.'}), 400 file = request.files['photo'] if file.filename == '': return jsonify({'success': False, 'error': '파일을 선택해주세요.'}), 400 # 파일 확장자 체크 allowed = {'png', 'jpg', 'jpeg', 'gif', 'webp'} ext = file.filename.rsplit('.', 1)[-1].lower() if '.' in file.filename else '' if ext not in allowed: return jsonify({'success': False, 'error': '지원하지 않는 이미지 형식입니다.'}), 400 # 파일 저장 import uuid filename = f"pet_{pet_id}_{uuid.uuid4().hex[:8]}.{ext}" upload_dir = Path(app.root_path) / 'static' / 'uploads' / 'pets' upload_dir.mkdir(parents=True, exist_ok=True) filepath = upload_dir / filename file.save(str(filepath)) # DB 업데이트 photo_url = f"/static/uploads/pets/{filename}" cursor.execute(""" UPDATE pets SET photo_url = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ? """, (photo_url, pet_id)) conn.commit() logging.info(f"반려동물 사진 업로드: pet_id={pet_id}, filename={filename}") return jsonify({ 'success': True, 'photo_url': photo_url, 'message': f'{pet["name"]} 사진이 등록되었습니다!' }) except Exception as e: logging.error(f"반려동물 사진 업로드 오류: {e}") return jsonify({'success': False, 'error': str(e)}), 500 @app.route('/api/pets/breeds/') def get_breeds(species): """종류별 품종 목록 조회""" if species == 'dog': return jsonify({'success': True, 'breeds': DOG_BREEDS}) elif species == 'cat': return jsonify({'success': True, 'breeds': CAT_BREEDS}) else: 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에서 적립 사용자 조회 (user_id 포함) sqlite_cursor.execute(""" SELECT u.id as user_id, 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() # 적립 사용자 정보 분리 claimed_user_id = None if claimed_user and claimed_user['nickname'] and claimed_user['phone']: claimed_user_id = claimed_user['user_id'] claimed_name = claimed_user['nickname'] claimed_phone = claimed_user['phone'] claimed_points = claimed_user['claimable_points'] else: claimed_name = "" claimed_phone = "" claimed_points = 0 # 반려동물 정보 조회 pets_list = [] if claimed_user_id: sqlite_cursor.execute(""" SELECT name, species, breed, photo_url FROM pets WHERE user_id = ? AND is_active = 1 """, (claimed_user_id,)) pets_rows = sqlite_cursor.fetchall() for pet in pets_rows: pets_list.append({ 'name': pet['name'], 'species': pet['species'], # dog, cat 'breed': pet['breed'] or '', 'photo_url': pet['photo_url'] or '' }) # 결제수단 판별 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, 'pets': pets_list # 반려동물 정보 }) 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() # 품목 상세 조회 (바코드 포함 - CD_GOODS 또는 CD_ITEM_UNIT_MEMBER에서) 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, COALESCE(NULLIF(G.Barcode, ''), (SELECT TOP 1 CD_CD_BARCODE FROM PM_DRUG.dbo.CD_ITEM_UNIT_MEMBER WHERE DrugCode = S.DrugCode) ) AS barcode 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 = [] seen_drugs = set() # 중복 제거용 for row in rows: drug_code = row[0] if drug_code in seen_drugs: continue seen_drugs.add(drug_code) items.append({ 'drug_code': drug_code, '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, 'barcode': row[5] or '' }) 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 # ═══════════════════════════════════════════════════════════════════════════════ # QR 라벨 생성 및 프린터 출력 API (Brother QL-810W) # ═══════════════════════════════════════════════════════════════════════════════ @app.route('/api/admin/qr/generate', methods=['POST']) def api_admin_qr_generate(): """ QR 토큰 생성 API - claim_tokens 테이블에 저장 - 미리보기 이미지 반환 (선택) """ try: data = request.get_json() order_no = data.get('order_no') amount = data.get('amount', 0) preview = data.get('preview', True) # 기본: 미리보기 if not order_no: return jsonify({'success': False, 'error': '주문번호가 필요합니다'}), 400 # 기존 모듈 import from utils.qr_token_generator import generate_claim_token, save_token_to_db from utils.qr_label_printer import print_qr_label # 거래 시간 조회 (MSSQL) mssql_engine = db_manager.get_engine('PM_PRES') mssql_conn = mssql_engine.raw_connection() cursor = mssql_conn.cursor() cursor.execute(""" SELECT InsertTime, SL_MY_sale FROM SALE_MAIN WHERE SL_NO_order = ? """, order_no) row = cursor.fetchone() mssql_conn.close() if not row: return jsonify({'success': False, 'error': f'거래를 찾을 수 없습니다: {order_no}'}), 404 transaction_time = row[0] or datetime.now() if amount <= 0: amount = float(row[1]) if row[1] else 0 # 1. 토큰 생성 token_info = generate_claim_token(order_no, amount) # 2. DB 저장 success, error = save_token_to_db( order_no, token_info['token_hash'], amount, token_info['claimable_points'], token_info['expires_at'], token_info['pharmacy_id'] ) if not success: return jsonify({'success': False, 'error': error}), 400 # 3. 미리보기 이미지 생성 image_url = None if preview: success, image_path = print_qr_label( token_info['qr_url'], order_no, amount, token_info['claimable_points'], transaction_time, preview_mode=True ) if success and image_path: # 상대 경로로 변환 filename = os.path.basename(image_path) image_url = f'/static/temp/{filename}' # temp 폴더를 static에서 접근 가능하게 복사 static_temp = os.path.join(os.path.dirname(__file__), 'static', 'temp') os.makedirs(static_temp, exist_ok=True) import shutil shutil.copy(image_path, os.path.join(static_temp, filename)) return jsonify({ 'success': True, 'order_no': order_no, 'amount': amount, 'claimable_points': token_info['claimable_points'], 'qr_url': token_info['qr_url'], 'expires_at': token_info['expires_at'].strftime('%Y-%m-%d %H:%M'), 'image_url': image_url }) except Exception as e: logging.error(f"QR 생성 오류: {e}") return jsonify({'success': False, 'error': str(e)}), 500 @app.route('/api/admin/qr/print', methods=['POST']) def api_admin_qr_print(): """ QR 라벨 프린터 출력 API (Brother QL-810W) """ try: data = request.get_json() order_no = data.get('order_no') printer_type = data.get('printer', 'brother') # 'brother' or 'pos' if not order_no: return jsonify({'success': False, 'error': '주문번호가 필요합니다'}), 400 # claim_tokens에서 정보 조회 sqlite_conn = db_manager.get_sqlite_connection() cursor = sqlite_conn.cursor() cursor.execute(""" SELECT token_hash, total_amount, claimable_points, created_at FROM claim_tokens WHERE transaction_id = ? """, (order_no,)) token_row = cursor.fetchone() if not token_row: return jsonify({'success': False, 'error': 'QR이 생성되지 않은 거래입니다. 먼저 생성해주세요.'}), 404 # 거래 시간 조회 (MSSQL) mssql_engine = db_manager.get_engine('PM_PRES') mssql_conn = mssql_engine.raw_connection() mssql_cursor = mssql_conn.cursor() mssql_cursor.execute(""" SELECT InsertTime FROM SALE_MAIN WHERE SL_NO_order = ? """, order_no) row = mssql_cursor.fetchone() mssql_conn.close() transaction_time = row[0] if row else datetime.now() # QR URL 재생성 (토큰 해시에서) from utils.qr_token_generator import QR_BASE_URL # claim_tokens에서 nonce를 저장하지 않으므로, 새로 생성 # 하지만 이미 저장된 경우 재출력만 하면 됨 # 실제로는 token_hash로 검증하므로 QR URL은 동일하게 유지해야 함 # 여기서는 간단히 재생성 (실제로는 nonce도 저장하는 게 좋음) from utils.qr_token_generator import generate_claim_token amount = token_row['total_amount'] claimable_points = token_row['claimable_points'] # 새 토큰 생성 (URL용) - 기존 토큰과 다르지만 적립 시 해시로 검증 # 주의: 실제로는 기존 토큰을 저장하고 재사용해야 함 # 여기서는 임시로 새 URL 생성 (인쇄만 다시 하는 케이스) token_info = generate_claim_token(order_no, amount) if printer_type == 'brother': from utils.qr_label_printer import print_qr_label success = print_qr_label( token_info['qr_url'], order_no, amount, claimable_points, transaction_time, preview_mode=False ) if success: return jsonify({ 'success': True, 'message': f'Brother QL-810W 라벨 출력 완료 ({claimable_points}P)' }) else: return jsonify({'success': False, 'error': 'Brother 프린터 전송 실패'}), 500 elif printer_type == 'pos': from utils.pos_qr_printer import print_qr_receipt_escpos # POS 프린터 설정 (config.json에서) config_path = os.path.join(os.path.dirname(__file__), 'config.json') pos_config = {} if os.path.exists(config_path): import json with open(config_path, 'r', encoding='utf-8') as f: config = json.load(f) pos_config = config.get('pos_printer', {}) if not pos_config.get('ip'): return jsonify({'success': False, 'error': 'POS 프린터 설정이 필요합니다'}), 400 success = print_qr_receipt_escpos( token_info['qr_url'], order_no, amount, claimable_points, transaction_time, pos_config['ip'], pos_config.get('port', 9100) ) if success: return jsonify({ 'success': True, 'message': f'POS 영수증 출력 완료 ({claimable_points}P)' }) else: return jsonify({'success': False, 'error': 'POS 프린터 전송 실패'}), 500 else: return jsonify({'success': False, 'error': f'지원하지 않는 프린터: {printer_type}'}), 400 except Exception as e: logging.error(f"QR 출력 오류: {e}") return jsonify({'success': False, 'error': str(e)}), 500 # ============================================================================ # OTC 용법 라벨 시스템 API # ============================================================================ @app.route('/admin/otc-labels') def admin_otc_labels(): """OTC 용법 라벨 관리 페이지""" return render_template('admin_otc_labels.html') @app.route('/api/admin/otc-labels', methods=['GET']) def api_get_otc_labels(): """OTC 라벨 프리셋 목록 조회""" try: conn = db_manager.get_sqlite_connection() cursor = conn.cursor() cursor.execute(""" SELECT id, barcode, drug_code, display_name, effect, dosage_instruction, usage_tip, use_wide_format, print_count, last_printed_at, created_at, updated_at FROM otc_label_presets ORDER BY updated_at DESC """) rows = cursor.fetchall() labels = [dict(row) for row in rows] return jsonify({ 'success': True, 'count': len(labels), 'labels': labels }) except Exception as e: logging.error(f"OTC 라벨 목록 조회 오류: {e}") return jsonify({'success': False, 'error': str(e)}), 500 @app.route('/api/admin/otc-labels/', methods=['GET']) def api_get_otc_label(barcode): """OTC 라벨 프리셋 단건 조회 (바코드 기준)""" try: conn = db_manager.get_sqlite_connection() cursor = conn.cursor() cursor.execute(""" SELECT id, barcode, drug_code, display_name, effect, dosage_instruction, usage_tip, use_wide_format, print_count, last_printed_at, created_at, updated_at FROM otc_label_presets WHERE barcode = ? """, (barcode,)) row = cursor.fetchone() if not row: return jsonify({'success': False, 'error': '등록된 프리셋이 없습니다.', 'exists': False}), 404 return jsonify({ 'success': True, 'exists': True, 'label': dict(row) }) except Exception as e: logging.error(f"OTC 라벨 조회 오류: {e}") return jsonify({'success': False, 'error': str(e)}), 500 @app.route('/api/admin/otc-labels', methods=['POST']) def api_upsert_otc_label(): """OTC 라벨 프리셋 등록/수정 (Upsert)""" try: data = request.get_json() if not data or not data.get('barcode'): return jsonify({'success': False, 'error': 'barcode는 필수입니다.'}), 400 barcode = data['barcode'] drug_code = data.get('drug_code', '') display_name = data.get('display_name', '') effect = data.get('effect', '') dosage_instruction = data.get('dosage_instruction', '') usage_tip = data.get('usage_tip', '') use_wide_format = data.get('use_wide_format', True) conn = db_manager.get_sqlite_connection() cursor = conn.cursor() # Upsert (INSERT OR REPLACE) cursor.execute(""" INSERT INTO otc_label_presets (barcode, drug_code, display_name, effect, dosage_instruction, usage_tip, use_wide_format, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?, CURRENT_TIMESTAMP) ON CONFLICT(barcode) DO UPDATE SET drug_code = excluded.drug_code, display_name = excluded.display_name, effect = excluded.effect, dosage_instruction = excluded.dosage_instruction, usage_tip = excluded.usage_tip, use_wide_format = excluded.use_wide_format, updated_at = CURRENT_TIMESTAMP """, (barcode, drug_code, display_name, effect, dosage_instruction, usage_tip, use_wide_format)) conn.commit() return jsonify({ 'success': True, 'message': f'라벨 프리셋 저장 완료 ({barcode})' }) except Exception as e: logging.error(f"OTC 라벨 저장 오류: {e}") return jsonify({'success': False, 'error': str(e)}), 500 @app.route('/api/admin/otc-labels/', methods=['DELETE']) def api_delete_otc_label(barcode): """OTC 라벨 프리셋 삭제""" try: conn = db_manager.get_sqlite_connection() cursor = conn.cursor() cursor.execute("DELETE FROM otc_label_presets WHERE barcode = ?", (barcode,)) conn.commit() if cursor.rowcount > 0: return jsonify({'success': True, 'message': f'삭제 완료 ({barcode})'}) else: return jsonify({'success': False, 'error': '존재하지 않는 프리셋'}), 404 except Exception as e: logging.error(f"OTC 라벨 삭제 오류: {e}") return jsonify({'success': False, 'error': str(e)}), 500 @app.route('/api/admin/otc-labels/preview', methods=['POST']) def api_preview_otc_label(): """OTC 라벨 미리보기 이미지 생성""" try: data = request.get_json() if not data: return jsonify({'success': False, 'error': '요청 데이터가 없습니다.'}), 400 drug_name = data.get('drug_name', '약품명') effect = data.get('effect', '') dosage_instruction = data.get('dosage_instruction', '') usage_tip = data.get('usage_tip', '') if not OTC_LABEL_AVAILABLE: return jsonify({'success': False, 'error': 'OTC 라벨 모듈이 로드되지 않았습니다.'}), 500 preview_url = generate_preview_image(drug_name, effect, dosage_instruction, usage_tip) if preview_url: return jsonify({ 'success': True, 'preview_url': preview_url }) else: return jsonify({'success': False, 'error': '미리보기 생성 실패'}), 500 except Exception as e: logging.error(f"OTC 라벨 미리보기 오류: {e}") return jsonify({'success': False, 'error': str(e)}), 500 @app.route('/api/admin/otc-labels/print', methods=['POST']) def api_print_otc_label(): """OTC 라벨 인쇄 (Brother QL-810W)""" try: data = request.get_json() if not data: return jsonify({'success': False, 'error': '요청 데이터가 없습니다.'}), 400 barcode = data.get('barcode') drug_name = data.get('drug_name', '약품명') effect = data.get('effect', '') dosage_instruction = data.get('dosage_instruction', '') usage_tip = data.get('usage_tip', '') if not OTC_LABEL_AVAILABLE: return jsonify({'success': False, 'error': 'OTC 라벨 모듈이 로드되지 않았습니다.'}), 500 success = print_otc_label(drug_name, effect, dosage_instruction, usage_tip) if success: # 인쇄 횟수 업데이트 if barcode: conn = db_manager.get_sqlite_connection() cursor = conn.cursor() cursor.execute(""" UPDATE otc_label_presets SET print_count = print_count + 1, last_printed_at = CURRENT_TIMESTAMP WHERE barcode = ? """, (barcode,)) conn.commit() return jsonify({ 'success': True, 'message': f'라벨 인쇄 완료: {drug_name}' }) else: return jsonify({'success': False, 'error': '프린터 전송 실패'}), 500 except Exception as e: logging.error(f"OTC 라벨 인쇄 오류: {e}") return jsonify({'success': False, 'error': str(e)}), 500 @app.route('/api/admin/otc-labels/search-mssql', methods=['GET']) def api_search_mssql_drug(): """MSSQL에서 약품 검색 (바코드 또는 이름)""" try: query = request.args.get('q', '').strip() if not query: return jsonify({'success': False, 'error': '검색어를 입력해주세요.'}), 400 mssql_session = db_manager.get_session('PM_DRUG') # 바코드 또는 이름으로 검색 (CD_ITEM_UNIT_MEMBER 바코드 포함) sql = text(""" SELECT TOP 20 G.DrugCode, COALESCE(NULLIF(G.Barcode, ''), (SELECT TOP 1 CD_CD_BARCODE FROM CD_ITEM_UNIT_MEMBER WHERE DrugCode = G.DrugCode) ) AS Barcode, G.GoodsName, G.Saleprice FROM CD_GOODS G WHERE G.GoodsName LIKE :query OR G.Barcode LIKE :query OR G.DrugCode IN (SELECT DrugCode FROM CD_ITEM_UNIT_MEMBER WHERE CD_CD_BARCODE LIKE :query) ORDER BY CASE WHEN G.Barcode = :exact THEN 0 ELSE 1 END, G.GoodsName """) rows = mssql_session.execute(sql, { 'query': f'%{query}%', 'exact': query }).fetchall() drugs = [] for row in rows: drugs.append({ 'drug_code': row.DrugCode, 'barcode': row.Barcode, 'goods_name': row.GoodsName, 'sale_price': float(row.Saleprice or 0) }) return jsonify({ 'success': True, 'count': len(drugs), 'drugs': drugs }) except Exception as e: logging.error(f"MSSQL 약품 검색 오류: {e}") return jsonify({'success': False, 'error': str(e)}), 500 # ============================================================ # 제품 이미지 관리 (yakkok 크롤러) # ============================================================ @app.route('/admin/product-images') def admin_product_images(): """제품 이미지 관리 어드민 페이지""" return render_template('admin_product_images.html') @app.route('/api/admin/product-images') def api_product_images_list(): """제품 이미지 목록 조회""" import sqlite3 try: db_path = os.path.join(os.path.dirname(__file__), 'db', 'product_images.db') conn = sqlite3.connect(db_path) conn.row_factory = sqlite3.Row cursor = conn.cursor() status_filter = request.args.get('status', '') search = request.args.get('search', '') limit = int(request.args.get('limit', 50)) offset = int(request.args.get('offset', 0)) where_clauses = [] params = [] if status_filter: # "failed" 필터는 failed + no_result 둘 다 포함 (통계와 일치시키기 위해) if status_filter == 'failed': where_clauses.append("status IN ('failed', 'no_result')") else: where_clauses.append("status = ?") params.append(status_filter) if search: where_clauses.append("(product_name LIKE ? OR barcode LIKE ?)") params.extend([f'%{search}%', f'%{search}%']) where_sql = " WHERE " + " AND ".join(where_clauses) if where_clauses else "" # 총 개수 cursor.execute(f"SELECT COUNT(*) FROM product_images {where_sql}", params) total = cursor.fetchone()[0] # 목록 조회 cursor.execute(f""" SELECT id, barcode, drug_code, product_name, thumbnail_base64, image_url, status, created_at, error_message FROM product_images {where_sql} ORDER BY created_at DESC LIMIT ? OFFSET ? """, params + [limit, offset]) items = [dict(row) for row in cursor.fetchall()] conn.close() return jsonify({ 'success': True, 'total': total, 'items': items }) except Exception as e: logging.error(f"제품 이미지 목록 조회 오류: {e}") return jsonify({'success': False, 'error': str(e)}), 500 @app.route('/api/admin/product-images/') def api_product_image_detail(barcode): """제품 이미지 상세 조회 (원본 base64 포함)""" import sqlite3 try: db_path = os.path.join(os.path.dirname(__file__), 'db', 'product_images.db') conn = sqlite3.connect(db_path) conn.row_factory = sqlite3.Row cursor = conn.cursor() cursor.execute("SELECT * FROM product_images WHERE barcode = ?", (barcode,)) row = cursor.fetchone() conn.close() if row: return jsonify({'success': True, 'image': dict(row)}) else: return jsonify({'success': False, 'error': '이미지 없음'}), 404 except Exception as e: return jsonify({'success': False, 'error': str(e)}), 500 @app.route('/api/admin/product-images/crawl-today', methods=['POST']) def api_crawl_today(): """특정 날짜 판매 제품 크롤링 (date 파라미터 없으면 오늘)""" try: from utils.yakkok_crawler import crawl_sales_by_date data = request.get_json() or {} date_str = data.get('date') # YYYY-MM-DD 형식 result = crawl_sales_by_date(date_str, headless=True) return jsonify({'success': True, 'result': result, 'date': date_str or 'today'}) except Exception as e: logging.error(f"크롤링 오류: {e}") return jsonify({'success': False, 'error': str(e)}), 500 @app.route('/api/admin/product-images/crawl', methods=['POST']) def api_crawl_products(): """특정 제품 크롤링""" try: from utils.yakkok_crawler import crawl_products data = request.get_json() products = data.get('products', []) # [(barcode, drug_code, product_name), ...] if not products: return jsonify({'success': False, 'error': '제품 목록 필요'}), 400 result = crawl_products(products, headless=True) return jsonify({'success': True, 'result': result}) except Exception as e: logging.error(f"크롤링 오류: {e}") return jsonify({'success': False, 'error': str(e)}), 500 @app.route('/api/admin/product-images/', methods=['DELETE']) def api_delete_product_image(barcode): """제품 이미지 삭제""" import sqlite3 try: db_path = os.path.join(os.path.dirname(__file__), 'db', 'product_images.db') conn = sqlite3.connect(db_path) cursor = conn.cursor() cursor.execute("DELETE FROM product_images WHERE barcode = ?", (barcode,)) conn.commit() conn.close() return jsonify({'success': True}) except Exception as e: return jsonify({'success': False, 'error': str(e)}), 500 @app.route('/api/admin/product-images//replace', methods=['POST']) def api_replace_product_image(barcode): """이미지 URL로 교체""" import sqlite3 import requests import base64 from PIL import Image from io import BytesIO try: data = request.get_json() image_url = data.get('image_url', '').strip() if not image_url: return jsonify({'success': False, 'error': 'URL 필요'}), 400 # 다양한 User-Agent와 헤더로 시도 headers_list = [ { 'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36', 'Accept': 'image/avif,image/webp,image/apng,image/svg+xml,image/*,*/*;q=0.8', 'Accept-Language': 'ko-KR,ko;q=0.9,en-US;q=0.8,en;q=0.7', 'Referer': 'https://www.google.com/', }, { 'User-Agent': 'Mozilla/5.0 (iPhone; CPU iPhone OS 16_0 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/16.0 Mobile/15E148 Safari/604.1', 'Accept': 'image/*,*/*;q=0.8', }, { 'User-Agent': 'Googlebot-Image/1.0', } ] response = None for headers in headers_list: try: response = requests.get(image_url, headers=headers, timeout=15, allow_redirects=True) if response.status_code == 200 and len(response.content) > 1000: break except: continue if not response or response.status_code != 200: return jsonify({'success': False, 'error': f'이미지 다운로드 실패 (상태: {response.status_code if response else "연결실패"})'}), 400 # PIL로 이미지 처리 try: img = Image.open(BytesIO(response.content)) # RGBA -> RGB 변환 if img.mode == 'RGBA': bg = Image.new('RGB', img.size, (255, 255, 255)) bg.paste(img, mask=img.split()[3]) img = bg elif img.mode != 'RGB': img = img.convert('RGB') # 리사이즈 (최대 500px) max_size = 500 if max(img.size) > max_size: ratio = max_size / max(img.size) new_size = tuple(int(dim * ratio) for dim in img.size) img = img.resize(new_size, Image.LANCZOS) # base64 변환 buffer = BytesIO() img.save(buffer, format='JPEG', quality=85) image_base64 = base64.b64encode(buffer.getvalue()).decode('utf-8') # 썸네일 생성 thumb_size = 100 ratio = thumb_size / max(img.size) thumb_img = img.resize(tuple(int(dim * ratio) for dim in img.size), Image.LANCZOS) thumb_buffer = BytesIO() thumb_img.save(thumb_buffer, format='JPEG', quality=80) thumbnail_base64 = base64.b64encode(thumb_buffer.getvalue()).decode('utf-8') except Exception as e: return jsonify({'success': False, 'error': f'이미지 처리 실패: {str(e)}'}), 400 # SQLite 업데이트 (기존 값 유지) db_path = os.path.join(os.path.dirname(__file__), 'db', 'product_images.db') conn = sqlite3.connect(db_path) cursor = conn.cursor() # 기존 레코드 확인 cursor.execute("SELECT product_name, drug_code FROM product_images WHERE barcode = ?", (barcode,)) existing = cursor.fetchone() if existing: # 기존 레코드 있으면 이미지만 업데이트 (product_name, drug_code 유지) cursor.execute(""" UPDATE product_images SET image_base64 = ?, thumbnail_base64 = ?, image_url = ?, status = 'manual', error_message = NULL, updated_at = datetime('now') WHERE barcode = ? """, (image_base64, thumbnail_base64, image_url, barcode)) else: # 레코드가 없으면 새로 생성 (product_name은 barcode로 임시 저장) cursor.execute(""" INSERT INTO product_images (barcode, image_base64, thumbnail_base64, image_url, status, product_name) VALUES (?, ?, ?, ?, 'manual', ?) """, (barcode, image_base64, thumbnail_base64, image_url, barcode)) conn.commit() conn.close() return jsonify({'success': True, 'message': '이미지 교체 완료'}) except Exception as e: logging.error(f"이미지 교체 오류: {e}") return jsonify({'success': False, 'error': str(e)}), 500 @app.route('/api/admin/product-images//upload', methods=['POST']) def api_upload_product_image(barcode): """카메라 촬영 이미지 업로드 (base64 -> 1:1 크롭 -> 800x800 리사이즈)""" import sqlite3 import base64 from PIL import Image from io import BytesIO try: data = request.get_json() or {} image_data = (data.get('image_data') or '').strip() product_name = data.get('product_name') or barcode if not image_data: return jsonify({'success': False, 'error': '이미지 데이터 필요'}), 400 # data:image/...;base64, 접두사 제거 if ',' in image_data: image_data = image_data.split(',', 1)[1] try: # base64 디코딩 image_bytes = base64.b64decode(image_data) img = Image.open(BytesIO(image_bytes)) # RGBA -> RGB 변환 (PNG 등 투명 배경 처리) if img.mode == 'RGBA': bg = Image.new('RGB', img.size, (255, 255, 255)) bg.paste(img, mask=img.split()[3]) img = bg elif img.mode != 'RGB': img = img.convert('RGB') # 1:1 중앙 크롭 (정사각형) width, height = img.size min_dim = min(width, height) left = (width - min_dim) // 2 top = (height - min_dim) // 2 right = left + min_dim bottom = top + min_dim img = img.crop((left, top, right, bottom)) # 800x800 리사이즈 target_size = 800 img = img.resize((target_size, target_size), Image.LANCZOS) # base64 변환 (원본) buffer = BytesIO() img.save(buffer, format='JPEG', quality=90) image_base64 = base64.b64encode(buffer.getvalue()).decode('utf-8') # 썸네일 생성 (200x200) thumb_size = 200 thumb_img = img.resize((thumb_size, thumb_size), Image.LANCZOS) thumb_buffer = BytesIO() thumb_img.save(thumb_buffer, format='JPEG', quality=85) thumbnail_base64 = base64.b64encode(thumb_buffer.getvalue()).decode('utf-8') except Exception as e: return jsonify({'success': False, 'error': f'이미지 처리 실패: {str(e)}'}), 400 # SQLite 저장 db_path = os.path.join(os.path.dirname(__file__), 'db', 'product_images.db') conn = sqlite3.connect(db_path) cursor = conn.cursor() # 기존 레코드 확인 cursor.execute("SELECT product_name, drug_code FROM product_images WHERE barcode = ?", (barcode,)) existing = cursor.fetchone() if existing: # 기존 레코드 있으면 이미지만 업데이트 cursor.execute(""" UPDATE product_images SET image_base64 = ?, thumbnail_base64 = ?, image_url = NULL, status = 'manual', error_message = NULL, updated_at = datetime('now') WHERE barcode = ? """, (image_base64, thumbnail_base64, barcode)) else: # 새 레코드 생성 cursor.execute(""" INSERT INTO product_images (barcode, product_name, image_base64, thumbnail_base64, status) VALUES (?, ?, ?, ?, 'manual') """, (barcode, product_name, image_base64, thumbnail_base64)) conn.commit() conn.close() return jsonify({'success': True, 'message': '촬영 이미지 저장 완료'}) except Exception as e: logging.error(f"이미지 업로드 오류: {e}") return jsonify({'success': False, 'error': str(e)}), 500 @app.route('/api/admin/product-images/stats') def api_product_images_stats(): """이미지 통계""" import sqlite3 try: db_path = os.path.join(os.path.dirname(__file__), 'db', 'product_images.db') conn = sqlite3.connect(db_path) cursor = conn.cursor() cursor.execute(""" SELECT status, COUNT(*) as count FROM product_images GROUP BY status """) stats = {row[0]: row[1] for row in cursor.fetchall()} cursor.execute("SELECT COUNT(*) FROM product_images") total = cursor.fetchone()[0] conn.close() return jsonify({ 'success': True, 'total': total, 'stats': stats }) except Exception as e: return jsonify({'success': False, 'error': str(e)}), 500 # ═══════════════════════════════════════════════════════════════════════════════ # 동물약 정보 인쇄 API (ESC/POS 80mm) # ═══════════════════════════════════════════════════════════════════════════════ @app.route('/api/animal-drug-info/print', methods=['POST']) def api_animal_drug_info_print(): """동물약 정보 인쇄 (APC로 PostgreSQL 조회 후 ESC/POS 출력)""" try: import re from html import unescape data = request.get_json() apc = data.get('apc', '') product_name = data.get('product_name', '') if not apc: return jsonify({'success': False, 'error': 'APC 코드가 필요합니다'}), 400 # PostgreSQL에서 약품 정보 조회 try: from sqlalchemy import create_engine pg_engine = create_engine( 'postgresql://admin:trajet6640@192.168.0.87:5432/apdb_master', connect_args={'connect_timeout': 5} ) with pg_engine.connect() as conn: result = conn.execute(text(""" SELECT a.product_name, a.company_name, a.main_ingredient, a.efficacy_effect, a.dosage_instructions, a.precautions, a.weight_min_kg, a.weight_max_kg, a.pet_size_label, a.component_code, g.component_name_ko, g.dosing_interval_adult, g.dosing_interval_high_risk, g.dosing_interval_puppy, g.companion_drugs FROM apc a LEFT JOIN component_guide g ON a.component_code = g.component_code WHERE a.apc = :apc LIMIT 1 """), {'apc': apc}) row = result.fetchone() # 포장단위 APC → 대표 APC 폴백 if not row and len(apc) == 13 and apc.startswith('023'): item_prefix = apc[:8] result = conn.execute(text(""" SELECT a.product_name, a.company_name, a.main_ingredient, a.efficacy_effect, a.dosage_instructions, a.precautions, a.weight_min_kg, a.weight_max_kg, a.pet_size_label, a.component_code, g.component_name_ko, g.dosing_interval_adult, g.dosing_interval_high_risk, g.dosing_interval_puppy, g.companion_drugs FROM apc a LEFT JOIN component_guide g ON a.component_code = g.component_code WHERE a.apc LIKE :prefix ORDER BY LENGTH(a.apc) LIMIT 1 """), {'prefix': f'{item_prefix}%'}) row = result.fetchone() if not row: return jsonify({'success': False, 'error': f'APC {apc} 정보를 찾을 수 없습니다'}), 404 except Exception as e: logging.error(f"PostgreSQL 조회 오류: {e}") return jsonify({'success': False, 'error': f'DB 조회 오류: {str(e)}'}), 500 # HTML 태그 제거 함수 def strip_html(html_text): if not html_text: return '' # HTML 태그 제거 (줄바꿈 보존) #

,
, 등을 줄바꿈으로 변환 text = re.sub(r'

||', '\n', html_text) text = re.sub(r'<[^>]+>', '', text) # HTML 엔티티 변환 text = unescape(text) # 표 형식 감지 (─ 또는 ====/---- 포함) if '─' in text or '━' in text or ('======' in text and '------' in text): # 표 형식: 각 줄의 앞뒤 공백만 정리, 줄 내 공백은 유지 lines = text.split('\n') cleaned = [] for line in lines: line = line.strip() if line: cleaned.append(line) return '\n'.join(cleaned) else: # 일반 텍스트: 연속 공백/줄바꿈 정리 text = re.sub(r'\s+', ' ', text).strip() return text # 항목별 줄바꿈 처리 (가. 나. 다. 라. / 1) 2) 3) 등) def format_for_print(text): if not text: return '' # 가. 나. 다. 라. 마. 바. 사. 아. 자. 앞에 줄바꿈 text = re.sub(r'\s*(가|나|다|라|마|바|사|아|자)\.\s*', r'\n\1. ', text) # 1) 2) 3) 등 앞에 줄바꿈 (단, 문장 시작이 아닌 경우) text = re.sub(r'\s+(\d+)\)\s*', r'\n \1) ', text) # 첫 줄바꿈 제거 text = text.strip() return text # 텍스트를 줄 단위로 분리 (80mm ≈ 42자) def wrap_text(text, width=40): lines = [] words = text.split() current_line = "" for word in words: if len(current_line) + len(word) + 1 <= width: current_line += (" " if current_line else "") + word else: if current_line: lines.append(current_line) current_line = word if current_line: lines.append(current_line) return lines # 데이터 파싱 pg_product_name = row.product_name or product_name company = row.company_name or '' ingredient = row.main_ingredient or '' efficacy = strip_html(row.efficacy_effect) dosage = strip_html(row.dosage_instructions) precautions = strip_html(row.precautions) # 80mm 프린터 = 48자 기준 LINE = "=" * 48 THIN = "-" * 48 # 텍스트 메시지 생성 (48자 기준 중앙정렬) message = f""" {LINE} [ 애니팜 투약지도서 ] {LINE} {pg_product_name} """ if company: message += f"제조: {company}\n" if ingredient and ingredient != 'NaN': message += f""" {THIN} ▶ 주성분 """ for line in wrap_text(ingredient, 46): message += f" {line}\n" if efficacy: message += f""" {THIN} ▶ 효능효과 """ formatted_efficacy = format_for_print(efficacy) for para in formatted_efficacy.split('\n'): for line in wrap_text(para.strip(), 44): message += f" {line}\n" if dosage: message += f""" {THIN} ▶ 용법용량 """ # 표 형식 감지 has_box_table = '─' in dosage or '━' in dosage has_ascii_table = '======' in dosage and '------' in dosage if has_box_table: # ─ 표: 줄바꿈 유지 for line in dosage.split('\n'): line = line.strip() if line: message += f"{line}\n" elif has_ascii_table: # ===/--- 표: 구분선 제거, 데이터만 정리 for line in dosage.split('\n'): stripped = line.strip() if not stripped: continue if stripped.startswith('===') or stripped.startswith('---'): message += f" {'─' * 44}\n" else: # 공백 정렬된 열을 적절히 정리 message += f" {stripped}\n" else: formatted_dosage = format_for_print(dosage) for para in formatted_dosage.split('\n'): for line in wrap_text(para.strip(), 44): message += f" {line}\n" # 투약 주기 (component_guide JOIN) if row.dosing_interval_adult: message += f""" {THIN} ★ 투약 주기 ★ """ message += f" 일반: {row.dosing_interval_adult}\n" if row.dosing_interval_high_risk: message += f" 고위험: {row.dosing_interval_high_risk}\n" if row.dosing_interval_puppy: message += f" 새끼: {row.dosing_interval_puppy}\n" # 병용약 권장 (component_guide JOIN) if row.companion_drugs: message += f""" {THIN} ★ 함께 투약 권장 ★ """ for line in wrap_text(row.companion_drugs, 44): message += f" {line}\n" # 주의사항 (마지막) if precautions: message += f""" {THIN} ▶ 주의사항 """ formatted_precautions = format_for_print(precautions) for para in formatted_precautions.split('\n'): for line in wrap_text(para.strip(), 44): message += f" {line}\n" message += f""" {LINE} 청 춘 약 국 Tel: 033-481-5222 """ # 네트워크 프린터로 인쇄 (pos_printer 사용) from pos_printer import print_text result = print_text(message, cut=True) if result: return jsonify({'success': True, 'message': '동물약 안내서 인쇄 완료'}) else: return jsonify({'success': False, 'error': '프린터 출력 실패'}), 500 except Exception as e: logging.error(f"동물약 정보 인쇄 API 오류: {e}") return jsonify({'success': False, 'error': str(e)}), 500 @app.route('/api/animal-drug-info/preview', methods=['POST']) def api_animal_drug_info_preview(): """동물약 정보 미리보기 (텍스트 반환)""" try: import re from html import unescape data = request.get_json() apc = data.get('apc', '') if not apc: return jsonify({'success': False, 'error': 'APC 코드가 필요합니다'}), 400 # PostgreSQL에서 약품 정보 조회 try: from sqlalchemy import create_engine pg_engine = create_engine( 'postgresql://admin:trajet6640@192.168.0.87:5432/apdb_master', connect_args={'connect_timeout': 5} ) with pg_engine.connect() as conn: result = conn.execute(text(""" SELECT a.product_name, a.company_name, a.main_ingredient, a.efficacy_effect, a.dosage_instructions, a.precautions, a.component_code, g.component_name_ko, g.dosing_interval_adult, g.dosing_interval_high_risk, g.dosing_interval_puppy, g.companion_drugs, g.contraindication as guide_contraindication FROM apc a LEFT JOIN component_guide g ON a.component_code = g.component_code WHERE a.apc = :apc LIMIT 1 """), {'apc': apc}) row = result.fetchone() # 포장단위 APC → 대표 APC 폴백 (앞 8자리 품목코드로 검색) if not row and len(apc) == 13 and apc.startswith('023'): item_prefix = apc[:8] result = conn.execute(text(""" SELECT a.product_name, a.company_name, a.main_ingredient, a.efficacy_effect, a.dosage_instructions, a.precautions, a.component_code, g.component_name_ko, g.dosing_interval_adult, g.dosing_interval_high_risk, g.dosing_interval_puppy, g.companion_drugs, g.contraindication as guide_contraindication FROM apc a LEFT JOIN component_guide g ON a.component_code = g.component_code WHERE a.apc LIKE :prefix ORDER BY LENGTH(a.apc) LIMIT 1 """), {'prefix': f'{item_prefix}%'}) row = result.fetchone() if not row: return jsonify({'success': False, 'error': f'APC {apc} 정보 없음'}), 404 except Exception as e: return jsonify({'success': False, 'error': f'DB 오류: {str(e)}'}), 500 # HTML 태그 제거 (표 형식은 줄바꿈 유지) def strip_html(html_text): if not html_text: return '' #

, → 줄바꿈 text = re.sub(r'

|', '\n', html_text) text = re.sub(r'<[^>]+>', '', text) text = unescape(text) # 표 형식 감지 (─ 또는 ====/---- 포함) if '─' in text or '━' in text or ('======' in text and '------' in text): lines = [l.strip() for l in text.split('\n') if l.strip()] return '\n'.join(lines) else: text = re.sub(r'\s+', ' ', text).strip() return text # 항목별 줄바꿈 처리 (가. 나. 다. 라. / 1) 2) 3) 등) def format_items(text): if not text: return '' # 가. 나. 다. 라. 마. 바. 사. 아. 자. 앞에 줄바꿈 text = re.sub(r'\s*(가|나|다|라|마|바|사|아|자)\.\s*', r'\n\1. ', text) # 1) 2) 3) 등 앞에 줄바꿈 text = re.sub(r'\s+(\d+)\)\s*', r'\n \1) ', text) return text.strip() # 표 형식을 HTML 테이블로 변환 def format_table_html(text): if not text: return '' has_box_line = '─' in text or '━' in text has_ascii_table = '======' in text and '------' in text # 표가 없으면 일반 처리 if not has_box_line and not has_ascii_table: return format_items(text) # ── (A) 안텔민 형식: ─ 구분 + "체중(kg)" 헤더 + "투여정수" 데이터 (2행 표) ── if has_box_line: lines = [l.strip() for l in text.split('\n') if l.strip() and '─' not in l and '━' not in l] header_line = None data_line = None other_lines = [] for line in lines: if '체중' in line and 'kg' in line.lower(): header_line = line elif '투여' in line and ('정수' in line or '정' in line): data_line = line else: other_lines.append(line) result = '\n'.join(other_lines) if header_line and data_line: headers = re.split(r'\s{2,}', header_line) values = re.split(r'\s{2,}', data_line) html = '' html += '' for h in headers: html += f'' html += '' html += '' for v in values: html += f'' html += '' html += '
{h}
{v}
' result = result + '\n' + html if result else html return result # ── (B) 넥스가드 형식: ====/---- 구분 + 다행 테이블 ── if has_ascii_table: lines = text.split('\n') before_table = [] table_rows = [] after_table = [] header_cols = [] in_table = False table_ended = False for line in lines: stripped = line.strip() if not stripped: continue is_eq_sep = stripped.startswith('===') is_dash_sep = stripped.startswith('---') if is_eq_sep: if in_table: # 두 번째 === → 테이블 끝 table_ended = True else: # 첫 번째 === → 테이블 시작 in_table = True continue if is_dash_sep: # --- 는 행 구분선 → 건너뛰기 continue if not in_table: before_table.append(stripped) continue if table_ended: after_table.append(stripped) continue # ㅣ 또는 | 구분자 감지 if 'ㅣ' in stripped or '|' in stripped: sep = 'ㅣ' if 'ㅣ' in stripped else '|' cells = [c.strip() for c in stripped.split(sep) if c.strip()] else: cells = re.split(r'\s{2,}', stripped) # 테이블 헤더 행 감지 if '체중' in stripped and not header_cols: header_cols = cells continue # 데이터 행 if len(cells) >= 2: table_rows.append(cells) result_parts = [] if before_table: result_parts.append(format_items('\n'.join(before_table))) if header_cols and table_rows: html = '' html += '' for h in header_cols: html += f'' html += '' for i, row in enumerate(table_rows): bg = '#fff' if i % 2 == 0 else '#f8fafc' html += f'' for cell in row: html += f'' # 셀 수가 헤더보다 적으면 빈 셀 채우기 for _ in range(len(header_cols) - len(row)): html += '' html += '' html += '
{h}
{cell}
' result_parts.append(html) if after_table: result_parts.append(format_items('\n'.join(after_table))) return '\n'.join(result_parts) return format_items(text) def format_dosage(raw_html): """dosage_instructions 처리: 원본 HTML table 보존 or 텍스트 표 변환""" if not raw_html: return '' # 원본에 태그가 있으면 → HTML 테이블 보존 + 스타일 적용 if ']*class="_table_wrap', raw_html, maxsplit=1)[0] if '_table_wrap' in raw_html else raw_html.split('(.*)', raw_html, re.DOTALL) after_html = after_match.group(1) if after_match else '' # 앞부분 텍스트 처리 before_text = strip_html(before_html) before_text = format_items(before_text) if before_text else '' # 테이블 추출 및 스타일 적용 table_match = re.search(r']*>(.*?)
', raw_html, re.DOTALL) if table_match: table_inner = table_match.group(1) # caption, hidden 요소 제거 table_inner = re.sub(r'.*?', '', table_inner, flags=re.DOTALL) # 기존 style 제거하고 새 스타일 적용 table_inner = re.sub(r']*>', '', table_inner) table_inner = re.sub(r']*>', '', table_inner) # 첫 번째 tr에 헤더 배경색 적용 table_inner = re.sub(r']*>', '', table_inner) table_inner = table_inner.replace('', '', 1) # p 태그 제거 (셀 내부) table_inner = re.sub(r']*>', '', table_inner) table_inner = re.sub(r'

', '
', table_inner) # tbody 유지 styled_table = f'{table_inner}
' else: styled_table = '' # 뒷부분 텍스트 처리 after_text = strip_html(after_html) after_text = format_items(after_text) if after_text else '' parts = [p for p in [before_text, styled_table, after_text] if p] return '\n'.join(parts) # 원본에 없으면 기존 로직 return format_table_html(strip_html(raw_html)) # 투약주기 조합 dosing_interval = None if row.dosing_interval_adult: parts = [] parts.append(f"일반: {row.dosing_interval_adult}") if row.dosing_interval_high_risk: parts.append(f"고위험: {row.dosing_interval_high_risk}") if row.dosing_interval_puppy: parts.append(f"새끼: {row.dosing_interval_puppy}") dosing_interval = '\n'.join(parts) return jsonify({ 'success': True, 'data': { 'product_name': row.product_name, 'company_name': row.company_name, 'main_ingredient': row.main_ingredient if row.main_ingredient != 'NaN' else None, 'efficacy_effect': format_items(strip_html(row.efficacy_effect)), 'dosage_instructions': format_dosage(row.dosage_instructions), 'dosage_has_table': any(c in (row.dosage_instructions or '') for c in ('─', '━', '======', '') def api_paai_log_detail(log_id): """PAAI 로그 상세 조회""" from db.paai_logger import get_log_detail try: log = get_log_detail(log_id) if log: return jsonify({'success': True, 'log': log}) else: return jsonify({'success': False, 'error': '로그를 찾을 수 없습니다.'}), 404 except Exception as e: return jsonify({'success': False, 'error': str(e)}), 500 # ══════════════════════════════════════════════════════════════════ # 모바일 이미지 업로드 세션 (QR 기반) # ══════════════════════════════════════════════════════════════════ import uuid from datetime import datetime, timedelta # 메모리 기반 세션 저장소 (서버 재시작 시 초기화됨) upload_sessions = {} def cleanup_expired_sessions(): """만료된 세션 정리""" now = datetime.now() expired = [sid for sid, s in upload_sessions.items() if s['expires_at'] < now] for sid in expired: del upload_sessions[sid] @app.route('/api/upload-session', methods=['POST']) def api_create_upload_session(): """업로드 세션 생성 (QR용)""" cleanup_expired_sessions() data = request.get_json() or {} barcode = data.get('barcode', '') product_name = data.get('product_name', barcode) # 없으면 바코드 사용 if not barcode: return jsonify({'success': False, 'error': '바코드가 필요합니다'}), 400 session_id = str(uuid.uuid4())[:12] # 짧은 ID expires_at = datetime.now() + timedelta(minutes=10) upload_sessions[session_id] = { 'barcode': barcode, 'product_name': product_name, 'created_at': datetime.now(), 'expires_at': expires_at, 'status': 'pending', # pending → uploaded 'image_base64': None } # QR URL 생성 qr_url = f"https://mile.0bin.in/upload/{session_id}?barcode={barcode}" return jsonify({ 'success': True, 'session_id': session_id, 'qr_url': qr_url, 'expires_in': 600 }) @app.route('/api/upload-session/') def api_get_upload_session(session_id): """업로드 세션 상태 확인 (폴링용)""" cleanup_expired_sessions() session = upload_sessions.get(session_id) if not session: return jsonify({'status': 'expired'}) result = {'status': session['status']} if session['status'] == 'uploaded' and session['image_base64']: result['image_base64'] = session['image_base64'] return jsonify(result) @app.route('/api/upload-session//image', methods=['POST']) def api_upload_session_image(session_id): """모바일에서 이미지 업로드""" session = upload_sessions.get(session_id) if not session: return jsonify({'success': False, 'error': '세션이 만료되었습니다'}), 404 if session['expires_at'] < datetime.now(): del upload_sessions[session_id] return jsonify({'success': False, 'error': '세션이 만료되었습니다'}), 404 # 이미지 데이터 받기 import base64 from PIL import Image from io import BytesIO if 'image' not in request.files: # base64로 받은 경우 data = request.get_json() or {} image_data_raw = data.get('image_base64') if not image_data_raw: return jsonify({'success': False, 'error': '이미지가 필요합니다'}), 400 else: # 파일로 받은 경우 file = request.files['image'] image_data_raw = base64.b64encode(file.read()).decode('utf-8') barcode = session['barcode'] product_name = session.get('product_name', barcode) try: # base64 디코딩 & PIL 이미지 처리 image_bytes = base64.b64decode(image_data_raw) img = Image.open(BytesIO(image_bytes)) # RGBA -> RGB 변환 if img.mode == 'RGBA': bg = Image.new('RGB', img.size, (255, 255, 255)) bg.paste(img, mask=img.split()[3]) img = bg elif img.mode != 'RGB': img = img.convert('RGB') # 1:1 중앙 크롭 width, height = img.size min_dim = min(width, height) left = (width - min_dim) // 2 top = (height - min_dim) // 2 img = img.crop((left, top, left + min_dim, top + min_dim)) # 800x800 리사이즈 img = img.resize((800, 800), Image.LANCZOS) # base64 변환 (원본) buffer = BytesIO() img.save(buffer, format='JPEG', quality=90) image_base64 = base64.b64encode(buffer.getvalue()).decode('utf-8') # 썸네일 생성 (200x200) thumb_img = img.resize((200, 200), Image.LANCZOS) thumb_buffer = BytesIO() thumb_img.save(thumb_buffer, format='JPEG', quality=85) thumbnail_base64 = base64.b64encode(thumb_buffer.getvalue()).decode('utf-8') except Exception as e: return jsonify({'success': False, 'error': f'이미지 처리 실패: {str(e)}'}), 400 # SQLite 저장 try: img_db_path = Path(__file__).parent / 'db' / 'product_images.db' conn = sqlite3.connect(str(img_db_path)) cursor = conn.cursor() # 기존 이미지 확인 cursor.execute('SELECT id FROM product_images WHERE barcode = ?', (barcode,)) existing = cursor.fetchone() if existing: cursor.execute(''' UPDATE product_images SET image_base64 = ?, thumbnail_base64 = ?, status = 'manual', updated_at = datetime('now') WHERE barcode = ? ''', (image_base64, thumbnail_base64, barcode)) else: cursor.execute(''' INSERT INTO product_images (barcode, product_name, image_base64, thumbnail_base64, status) VALUES (?, ?, ?, ?, 'manual') ''', (barcode, product_name, image_base64, thumbnail_base64)) conn.commit() conn.close() # 세션 상태 업데이트 session['status'] = 'uploaded' session['image_base64'] = thumbnail_base64 # 폴링용으로 썸네일 사용 return jsonify({'success': True, 'message': '이미지가 저장되었습니다'}) except Exception as e: return jsonify({'success': False, 'error': str(e)}), 500 @app.route('/upload/') def mobile_upload_page(session_id): """모바일 업로드 페이지""" session = upload_sessions.get(session_id) barcode = request.args.get('barcode', '') if not session: return render_template_string(''' 세션 만료
⏰ 세션이 만료되었습니다.

PC에서 다시 QR코드를 생성해주세요.
''') return render_template_string(''' 제품 이미지 촬영

📸 제품 이미지 촬영

바코드: {{ barcode }}
📷
카메라 버튼을 눌러
제품 사진을 촬영하세요
업로드 중...
''', session_id=session_id, barcode=barcode) # ============================================ # 동물약 챗봇 로그 API # ============================================ @app.route('/admin/animal-chat-logs') def admin_animal_chat_logs(): """동물약 챗봇 로그 페이지""" return render_template('admin_animal_chat_logs.html') @app.route('/api/animal-chat-logs') def api_animal_chat_logs(): """동물약 챗봇 로그 조회 API""" from utils.animal_chat_logger import get_logs, get_stats date_from = request.args.get('date_from') date_to = request.args.get('date_to') error_only = request.args.get('error_only') == 'true' limit = int(request.args.get('limit', 100)) offset = int(request.args.get('offset', 0)) logs = get_logs( limit=limit, offset=offset, date_from=date_from, date_to=date_to, error_only=error_only ) stats = get_stats(date_from=date_from, date_to=date_to) return jsonify({ 'success': True, 'logs': logs, 'stats': stats }) # ═══════════════════════════════════════════════════════════════════════════════ # 기간별 사용약품 조회 API # ═══════════════════════════════════════════════════════════════════════════════ @app.route('/api/drug-usage') def api_drug_usage(): """ 기간별 사용약품 조회 API 파라미터: - start_date: 시작일 (YYYYMMDD, 필수) - end_date: 종료일 (YYYYMMDD, 필수) - date_type: dispense (조제일, 기본) / expiry (소진일) - drug_code: 특정 약품코드 필터 - search: 약품명 검색 - limit: 결과 제한 (기본 100) """ try: # 파라미터 추출 start_date = request.args.get('start_date') end_date = request.args.get('end_date') date_type = request.args.get('date_type', 'dispense') # dispense or expiry drug_code = request.args.get('drug_code') search = request.args.get('search') limit = int(request.args.get('limit', 100)) # 필수 파라미터 확인 if not start_date or not end_date: return jsonify({ 'success': False, 'error': 'start_date와 end_date는 필수입니다 (YYYYMMDD 형식)' }), 400 # 날짜 형식 검증 (8자리 숫자) if not (start_date.isdigit() and len(start_date) == 8): return jsonify({'success': False, 'error': 'start_date는 YYYYMMDD 형식이어야 합니다'}), 400 if not (end_date.isdigit() and len(end_date) == 8): return jsonify({'success': False, 'error': 'end_date는 YYYYMMDD 형식이어야 합니다'}), 400 pres_session = db_manager.get_session('PM_PRES') # ───────────────────────────────────────── # 조제 데이터 쿼리 # ───────────────────────────────────────── if date_type == 'expiry': # 소진일(조제만료일) 기준 필터 rx_query = """ SELECT sp.DrugCode, g.GoodsName, m.PRINT_TYPE as category, COUNT(*) as rx_count, SUM(sp.QUAN * sp.QUAN_TIME * sp.Days) as total_qty FROM PS_sub_pharm sp INNER JOIN PS_main pm ON pm.PreSerial = sp.PreSerial INNER JOIN PM_DRUG.dbo.CD_GOODS g ON sp.DrugCode = g.DrugCode LEFT JOIN PM_DRUG.dbo.CD_MC m ON sp.DrugCode = m.DRUGCODE WHERE sp.PS_Type != '9' AND DATEADD(day, sp.Days, CONVERT(date, pm.Indate, 112)) BETWEEN CONVERT(date, :start_date, 112) AND CONVERT(date, :end_date, 112) """ else: # 조제일 기준 필터 (기본) rx_query = """ SELECT sp.DrugCode, g.GoodsName, m.PRINT_TYPE as category, COUNT(*) as rx_count, SUM(sp.QUAN * sp.QUAN_TIME * sp.Days) as total_qty FROM PS_sub_pharm sp INNER JOIN PS_main pm ON pm.PreSerial = sp.PreSerial INNER JOIN PM_DRUG.dbo.CD_GOODS g ON sp.DrugCode = g.DrugCode LEFT JOIN PM_DRUG.dbo.CD_MC m ON sp.DrugCode = m.DRUGCODE WHERE pm.Indate BETWEEN :start_date AND :end_date AND sp.PS_Type != '9' """ # 약품코드 필터 추가 if drug_code: rx_query += " AND sp.DrugCode = :drug_code" # 약품명 검색 필터 추가 if search: rx_query += " AND g.GoodsName LIKE :search" rx_query += " GROUP BY sp.DrugCode, g.GoodsName, m.PRINT_TYPE" rx_query += " ORDER BY rx_count DESC" # 파라미터 바인딩 params = {'start_date': start_date, 'end_date': end_date} if drug_code: params['drug_code'] = drug_code if search: params['search'] = f'%{search}%' rx_result = pres_session.execute(text(rx_query), params) rx_data = {} for row in rx_result: rx_data[row.DrugCode] = { 'drug_code': row.DrugCode, 'goods_name': row.GoodsName, 'category': row.category or '', 'rx_count': row.rx_count, 'rx_total_qty': float(row.total_qty) if row.total_qty else 0 } # ───────────────────────────────────────── # 입고 데이터 쿼리 (PM_DRUG에서 조회) # ───────────────────────────────────────── drug_session = db_manager.get_session('PM_DRUG') import_query = """ SELECT ws.DrugCode, COUNT(*) as import_count, SUM(ws.WH_NM_item_a) as total_qty FROM WH_sub ws INNER JOIN WH_main wm ON ws.WH_SR_stock = wm.WH_NO_stock """ # 약품명 검색 시 CD_GOODS 조인 추가 if search: import_query += " INNER JOIN CD_GOODS g ON ws.DrugCode = g.DrugCode" import_query += " WHERE wm.WH_DT_appl BETWEEN :start_date AND :end_date" # 약품코드 필터 추가 if drug_code: import_query += " AND ws.DrugCode = :drug_code" # 약품명 검색 필터 추가 if search: import_query += " AND g.GoodsName LIKE :search" import_query += " GROUP BY ws.DrugCode" import_params = {'start_date': start_date, 'end_date': end_date} if drug_code: import_params['drug_code'] = drug_code if search: import_params['search'] = f'%{search}%' import_result = drug_session.execute(text(import_query), import_params) import_data = {} for row in import_result: import_data[row.DrugCode] = { 'import_count': row.import_count, 'import_total_qty': float(row.total_qty) if row.total_qty else 0 } # ───────────────────────────────────────── # 결과 병합 (drug_code 기준) # ───────────────────────────────────────── all_drug_codes = set(rx_data.keys()) | set(import_data.keys()) items = [] for code in all_drug_codes: rx_info = rx_data.get(code, {}) import_info = import_data.get(code, {}) import_qty = import_info.get('import_total_qty', 0) rx_qty = rx_info.get('rx_total_qty', 0) item = { 'drug_code': code, 'goods_name': rx_info.get('goods_name', ''), 'category': rx_info.get('category', ''), 'rx_count': rx_info.get('rx_count', 0), 'rx_total_qty': rx_qty, 'import_count': import_info.get('import_count', 0), 'import_total_qty': import_qty, 'current_stock': 0 # IM_total에서 나중에 채움 } # 약품명이 없으면 (입고만 있는 경우) PM_DRUG에서 조회 if not item['goods_name'] and code in import_data: name_result = drug_session.execute( text("SELECT GoodsName FROM CD_GOODS WHERE DrugCode = :code"), {'code': code} ).fetchone() if name_result: item['goods_name'] = name_result.GoodsName items.append(item) # IM_total에서 현재 재고 조회 if items: drug_codes = [item['drug_code'] for item in items] placeholders = ','.join([f"'{c}'" for c in drug_codes]) stock_result = drug_session.execute(text(f""" SELECT DrugCode, IM_QT_sale_debit FROM IM_total WHERE DrugCode IN ({placeholders}) """)) stock_map = {row.DrugCode: float(row.IM_QT_sale_debit) if row.IM_QT_sale_debit else 0 for row in stock_result} for item in items: item['current_stock'] = stock_map.get(item['drug_code'], 0) # 조제 건수 기준 정렬 items.sort(key=lambda x: x['rx_count'], reverse=True) # limit 적용 items = items[:limit] return jsonify({ 'success': True, 'period': { 'start': start_date, 'end': end_date, 'date_type': date_type }, 'total_count': len(items), 'items': items }) except Exception as e: logging.error(f"drug-usage API 오류: {e}") import traceback traceback.print_exc() return jsonify({ 'success': False, 'error': str(e) }), 500 @app.route('/api/drug-usage//imports') def api_drug_usage_imports(drug_code): """약품별 입고 상세 API""" start_date = request.args.get('start_date', '') end_date = request.args.get('end_date', '') if not start_date or not end_date: return jsonify({'success': False, 'error': 'start_date, end_date 필수'}), 400 try: drug_session = db_manager.get_session('PM_DRUG') result = drug_session.execute(text(""" SELECT wm.WH_DT_appl as import_date, ws.WH_NM_item_a as quantity, ws.WH_MY_unit_a as unit_price, c.CD_NM_custom as supplier_name, c.CD_NM_charge1 as contact_person FROM WH_sub ws INNER JOIN WH_main wm ON ws.WH_SR_stock = wm.WH_NO_stock LEFT JOIN PM_BASE.dbo.CD_custom c ON wm.WH_CD_cust_sale = c.CD_CD_custom WHERE ws.DrugCode = :drug_code AND wm.WH_DT_appl BETWEEN :start_date AND :end_date ORDER BY wm.WH_DT_appl DESC """), {'drug_code': drug_code, 'start_date': start_date, 'end_date': end_date}) items = [] for row in result: qty = float(row.quantity) if row.quantity else 0 price = float(row.unit_price) if row.unit_price else 0 items.append({ 'import_date': row.import_date or '', 'quantity': qty, 'unit_price': price, 'amount': qty * price, 'supplier_name': row.supplier_name or '', 'person_name': row.contact_person or '' }) return jsonify({ 'success': True, 'drug_code': drug_code, 'total_count': len(items), 'items': items }) except Exception as e: logging.error(f"drug-usage imports API 오류: {e}") return jsonify({'success': False, 'error': str(e)}), 500 @app.route('/api/drug-usage//prescriptions') def api_drug_usage_prescriptions(drug_code): """약품별 조제(매출) 상세 API""" start_date = request.args.get('start_date', '') end_date = request.args.get('end_date', '') if not start_date or not end_date: return jsonify({'success': False, 'error': 'start_date, end_date 필수'}), 400 try: pres_session = db_manager.get_session('PM_PRES') result = pres_session.execute(text(""" SELECT pm.Indate as rx_date, CONVERT(varchar, DATEADD(day, sp.Days, CONVERT(date, pm.Indate, 112)), 112) as expiry_date, pm.Paname as patient_name, pm.OrderName as institution_name, sp.QUAN as dosage, sp.QUAN_TIME as frequency, sp.Days as days FROM PS_sub_pharm sp INNER JOIN PS_main pm ON pm.PreSerial = sp.PreSerial WHERE sp.DrugCode = :drug_code AND pm.Indate BETWEEN :start_date AND :end_date AND (sp.PS_Type IS NULL OR sp.PS_Type != '9') ORDER BY pm.Indate DESC """), {'drug_code': drug_code, 'start_date': start_date, 'end_date': end_date}) items = [] seen_patients = set() recent_patients = [] # 최근 조제받은 환자 (중복 제외, 최대 3명) for row in result: dosage = float(row.dosage) if row.dosage else 0 freq = float(row.frequency) if row.frequency else 0 days = int(row.days) if row.days else 0 patient = row.patient_name or '' # 중복 제외 환자 목록 (최근순, 최대 3명) if patient and patient not in seen_patients: seen_patients.add(patient) if len(recent_patients) < 3: recent_patients.append(patient) items.append({ 'rx_date': row.rx_date or '', 'expiry_date': row.expiry_date or '', 'patient_name': patient, 'institution_name': row.institution_name or '', 'dosage': dosage, 'frequency': freq, 'days': days, 'total_qty': dosage * freq * days }) return jsonify({ 'success': True, 'drug_code': drug_code, 'total_count': len(items), 'unique_patients': len(seen_patients), 'recent_patients': recent_patients, 'items': items }) except Exception as e: logging.error(f"drug-usage prescriptions API 오류: {e}") return jsonify({'success': False, 'error': str(e)}), 500 if __name__ == '__main__': import os PORT = 7001 # Flask reloader 자식 프로세스가 아닌 경우에만 체크 if os.environ.get('WERKZEUG_RUN_MAIN') != 'true': if not check_port_available(PORT): logging.warning(f"포트 {PORT}이 이미 사용 중입니다. 기존 프로세스를 종료합니다...") if kill_process_on_port(PORT): import time time.sleep(2) # 프로세스 종료 대기 else: logging.error(f"포트 {PORT} 해제 실패. 수동으로 확인하세요.") # 프로덕션 모드로 실행 app.run(host='0.0.0.0', port=PORT, debug=False, threaded=True)