pharmacy-pos-qr-system/backend/app.py
thug0bin c9f89cb9b0 fix(stock-analytics): 입출고 컬럼 의미 수정
- credit = 출고 (환자에게 판매)
- debit = 입고 (도매상에서 들어옴)
- 약국 재고 관점에서 올바르게 표시
2026-03-13 00:47:02 +09:00

10048 lines
388 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""
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/<transaction_id>')
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/<int:user_id>')
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 = []
# 5-1. MSSQL 직접 구매 이력 추가 (QR 적립 안 된 구매)
# 전화번호 → CUSCODE → SALE_MAIN 조회
try:
if user['phone']:
phone_clean = user['phone'].replace('-', '').replace(' ', '')
base_session = db_manager.get_session('PM_BASE')
pres_session = db_manager.get_session('PM_PRES')
# 전화번호로 CUSCODE 조회
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_clean}).fetchone()
if cus_row:
cuscode = cus_row.CUSCODE
# 이미 QR 적립된 transaction_id 목록
existing_tx_ids = {p['transaction_id'] for p in purchases}
# SALE_MAIN에서 이 고객의 구매 조회 (최근 30일, 최대 20건)
sale_query = text("""
SELECT TOP 20
M.SL_NO_order,
M.InsertTime,
M.SL_MY_sale
FROM SALE_MAIN M
WHERE M.SL_CD_custom = :cuscode
AND M.InsertTime >= DATEADD(day, -30, GETDATE())
ORDER BY M.InsertTime DESC
""")
sales = pres_session.execute(sale_query, {'cuscode': cuscode}).fetchall()
for sale in sales:
tx_id = sale.SL_NO_order
if tx_id in existing_tx_ids:
continue # 이미 QR 적립된 건 스킵
# 품목 조회
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 = :tx_id
""")
items_raw = pres_session.execute(items_query, {'tx_id': tx_id}).fetchall()
items = [{
'code': item.DrugCode,
'barcode': '',
'name': item.goods_name,
'qty': int(item.quantity or 0),
'price': int(item.price or 0),
'total': int(item.total or 0),
'categories': []
} for item in items_raw]
# 상품 요약
if items:
first_item_name = items[0]['name']
items_count = len(items)
items_summary = first_item_name if items_count == 1 else f"{first_item_name}{items_count - 1}"
else:
items_summary = "상품 정보 없음"
items_count = 0
purchases.append({
'transaction_id': tx_id,
'date': str(sale.InsertTime)[:16].replace('T', ' ') if sale.InsertTime else '-',
'amount': int(sale.SL_MY_sale or 0),
'points': 0, # QR 적립 안 됨
'items_summary': items_summary,
'items_count': items_count,
'items': items,
'source': 'pos' # POS 직접 매핑 구매
})
# 날짜순 정렬 (최신 먼저)
purchases.sort(key=lambda x: x['date'], reverse=True)
except Exception as pos_purchase_error:
logging.warning(f"POS 구매 이력 조회 실패 (user {user_id}): {pos_purchase_error}")
# 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/<int:user_id>', 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/<int:user_id>')
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/<int:rec_id>/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/drug-info/conversion-factor/<sung_code>')
def api_conversion_factor(sung_code):
"""
건조시럽 환산계수 조회 API
PostgreSQL drysyrup 테이블에서 SUNG_CODE로 환산계수 조회
mL → g 변환에 사용 (예: 120ml * 0.11 = 13.2g)
Args:
sung_code: 성분코드 (예: "535000ASY")
Returns:
{
"sung_code": "535000ASY",
"conversion_factor": 0.11,
"ingredient_name": "아목시실린수화물·클라불란산칼륨",
"product_name": "일성오구멘틴듀오시럽 228mg/5ml"
}
연결 실패/데이터 없음 시:
{"sung_code": "...", "conversion_factor": null, ...}
"""
try:
result = db_manager.get_conversion_factor(sung_code)
return jsonify({
'success': True,
'sung_code': sung_code,
'conversion_factor': result['conversion_factor'],
'ingredient_name': result['ingredient_name'],
'product_name': result['product_name']
})
except Exception as e:
logging.error(f"환산계수 조회 오류 (SUNG_CODE={sung_code}): {e}")
# 에러 발생해도 null 반환 (서비스 중단 방지)
return jsonify({
'success': True,
'sung_code': sung_code,
'conversion_factor': None,
'ingredient_name': None,
'product_name': None
})
@app.route('/api/drug-info/drysyrup/<sung_code>', methods=['GET'])
def api_drysyrup_get(sung_code):
"""
건조시럽 전체 정보 조회 API
PostgreSQL drysyrup 테이블에서 SUNG_CODE로 전체 정보 조회
"""
try:
session = db_manager.get_postgres_session()
if session is None:
return jsonify({
'success': True,
'exists': False,
'error': 'PostgreSQL 연결 실패'
})
query = text("""
SELECT
ingredient_code,
ingredient_name,
product_name,
conversion_factor,
post_prep_amount,
main_ingredient_amt,
storage_conditions,
expiration_date
FROM drysyrup
WHERE ingredient_code = :sung_code
LIMIT 1
""")
row = session.execute(query, {'sung_code': sung_code}).fetchone()
if not row:
return jsonify({
'success': True,
'exists': False
})
return jsonify({
'success': True,
'exists': True,
'sung_code': row[0],
'ingredient_name': row[1],
'product_name': row[2],
'conversion_factor': float(row[3]) if row[3] is not None else None,
'post_prep_amount': row[4],
'main_ingredient_amt': row[5],
'storage_conditions': row[6],
'expiration_date': row[7]
})
except Exception as e:
logging.error(f"건조시럽 조회 오류 (SUNG_CODE={sung_code}): {e}")
return jsonify({
'success': True,
'exists': False,
'error': str(e)
})
@app.route('/api/drug-info/drysyrup', methods=['POST'])
def api_drysyrup_create():
"""
건조시럽 신규 등록 API
"""
try:
data = request.get_json()
if not data or not data.get('sung_code'):
return jsonify({'success': False, 'error': '성분코드 필수'}), 400
session = db_manager.get_postgres_session()
if session is None:
return jsonify({'success': False, 'error': 'PostgreSQL 연결 실패'}), 500
# 중복 체크
check_query = text("SELECT 1 FROM drysyrup WHERE ingredient_code = :sung_code")
existing = session.execute(check_query, {'sung_code': data['sung_code']}).fetchone()
if existing:
return jsonify({'success': False, 'error': '이미 등록된 성분코드'}), 400
insert_query = text("""
INSERT INTO drysyrup (
ingredient_code, ingredient_name, product_name,
conversion_factor, post_prep_amount, main_ingredient_amt,
storage_conditions, expiration_date
) VALUES (
:sung_code, :ingredient_name, :product_name,
:conversion_factor, :post_prep_amount, :main_ingredient_amt,
:storage_conditions, :expiration_date
)
""")
session.execute(insert_query, {
'sung_code': data.get('sung_code'),
'ingredient_name': data.get('ingredient_name', ''),
'product_name': data.get('product_name', ''),
'conversion_factor': data.get('conversion_factor'),
'post_prep_amount': data.get('post_prep_amount', ''),
'main_ingredient_amt': data.get('main_ingredient_amt', ''),
'storage_conditions': data.get('storage_conditions', '실온'),
'expiration_date': data.get('expiration_date', '')
})
session.commit()
return jsonify({'success': True, 'message': '등록 완료'})
except Exception as e:
logging.error(f"건조시럽 등록 오류: {e}")
try:
session.rollback()
except:
pass
return jsonify({'success': False, 'error': str(e)}), 500
@app.route('/api/drug-info/drysyrup/<sung_code>', methods=['PUT'])
def api_drysyrup_update(sung_code):
"""
건조시럽 정보 수정 API
"""
try:
data = request.get_json()
if not data:
return jsonify({'success': False, 'error': '데이터 필수'}), 400
session = db_manager.get_postgres_session()
if session is None:
return jsonify({'success': False, 'error': 'PostgreSQL 연결 실패'}), 500
# 존재 여부 확인
check_query = text("SELECT 1 FROM drysyrup WHERE ingredient_code = :sung_code")
existing = session.execute(check_query, {'sung_code': sung_code}).fetchone()
if not existing:
# 없으면 신규 등록으로 처리
insert_query = text("""
INSERT INTO drysyrup (
ingredient_code, ingredient_name, product_name,
conversion_factor, post_prep_amount, main_ingredient_amt,
storage_conditions, expiration_date
) VALUES (
:sung_code, :ingredient_name, :product_name,
:conversion_factor, :post_prep_amount, :main_ingredient_amt,
:storage_conditions, :expiration_date
)
""")
session.execute(insert_query, {
'sung_code': sung_code,
'ingredient_name': data.get('ingredient_name', ''),
'product_name': data.get('product_name', ''),
'conversion_factor': data.get('conversion_factor'),
'post_prep_amount': data.get('post_prep_amount', ''),
'main_ingredient_amt': data.get('main_ingredient_amt', ''),
'storage_conditions': data.get('storage_conditions', '실온'),
'expiration_date': data.get('expiration_date', '')
})
else:
# 있으면 업데이트
update_query = text("""
UPDATE drysyrup SET
ingredient_name = :ingredient_name,
product_name = :product_name,
conversion_factor = :conversion_factor,
post_prep_amount = :post_prep_amount,
main_ingredient_amt = :main_ingredient_amt,
storage_conditions = :storage_conditions,
expiration_date = :expiration_date
WHERE ingredient_code = :sung_code
""")
session.execute(update_query, {
'sung_code': sung_code,
'ingredient_name': data.get('ingredient_name', ''),
'product_name': data.get('product_name', ''),
'conversion_factor': data.get('conversion_factor'),
'post_prep_amount': data.get('post_prep_amount', ''),
'main_ingredient_amt': data.get('main_ingredient_amt', ''),
'storage_conditions': data.get('storage_conditions', '실온'),
'expiration_date': data.get('expiration_date', '')
})
session.commit()
return jsonify({'success': True, 'message': '저장 완료'})
except Exception as e:
logging.error(f"건조시럽 수정 오류 (SUNG_CODE={sung_code}): {e}")
try:
session.rollback()
except:
pass
return jsonify({'success': False, 'error': str(e)}), 500
@app.route('/api/drug-info/drysyrup', methods=['GET'])
def api_drysyrup_list():
"""
건조시럽 전체 목록 조회 API
Query params:
q: 검색어 (성분명/제품명 검색)
Returns:
{ "success": true, "data": [...] }
"""
try:
session = db_manager.get_postgres_session()
if session is None:
return jsonify({
'success': False,
'error': 'PostgreSQL 연결 실패',
'data': []
})
search_query = request.args.get('q', '').strip()
if search_query:
query = text("""
SELECT
idx,
ingredient_code,
ingredient_name,
product_name,
conversion_factor,
post_prep_amount,
main_ingredient_amt,
storage_conditions,
expiration_date
FROM drysyrup
WHERE
ingredient_name ILIKE :search
OR product_name ILIKE :search
OR ingredient_code ILIKE :search
ORDER BY ingredient_name, product_name
""")
rows = session.execute(query, {'search': f'%{search_query}%'}).fetchall()
else:
query = text("""
SELECT
idx,
ingredient_code,
ingredient_name,
product_name,
conversion_factor,
post_prep_amount,
main_ingredient_amt,
storage_conditions,
expiration_date
FROM drysyrup
ORDER BY ingredient_name, product_name
""")
rows = session.execute(query).fetchall()
data = []
for row in rows:
data.append({
'idx': row[0],
'sung_code': row[1],
'ingredient_name': row[2],
'product_name': row[3],
'conversion_factor': float(row[4]) if row[4] is not None else None,
'post_prep_amount': row[5],
'main_ingredient_amt': row[6],
'storage_conditions': row[7],
'expiration_date': row[8]
})
return jsonify({
'success': True,
'data': data,
'count': len(data)
})
except Exception as e:
logging.error(f"건조시럽 목록 조회 오류: {e}")
return jsonify({
'success': False,
'error': str(e),
'data': []
}), 500
@app.route('/api/drug-info/drysyrup/<sung_code>', methods=['DELETE'])
def api_drysyrup_delete(sung_code):
"""
건조시럽 삭제 API
"""
try:
session = db_manager.get_postgres_session()
if session is None:
return jsonify({'success': False, 'error': 'PostgreSQL 연결 실패'}), 500
# 존재 여부 확인
check_query = text("SELECT 1 FROM drysyrup WHERE ingredient_code = :sung_code")
existing = session.execute(check_query, {'sung_code': sung_code}).fetchone()
if not existing:
return jsonify({'success': False, 'error': '존재하지 않는 성분코드'}), 404
# 삭제
delete_query = text("DELETE FROM drysyrup WHERE ingredient_code = :sung_code")
session.execute(delete_query, {'sung_code': sung_code})
session.commit()
return jsonify({'success': True, 'message': '삭제 완료'})
except Exception as e:
logging.error(f"건조시럽 삭제 오류 (SUNG_CODE={sung_code}): {e}")
try:
session.rollback()
except:
pass
return jsonify({'success': False, 'error': str(e)}), 500
@app.route('/admin/drysyrup')
def admin_drysyrup():
"""건조시럽 환산계수 관리 페이지"""
return render_template('admin_drysyrup.html')
# ==================== 입고이력 API ====================
@app.route('/api/drugs/<drug_code>/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/<drug_code>/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/<cus_code>/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/<drug_code>/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,
MAX(ISNULL(G.GoodsName, '알 수 없음')) as product_name,
MAX(ISNULL(G.SplName, '')) as supplier,
SUM(ISNULL(P.QUAN, 1)) as total_qty,
SUM(ISNULL(P.INV_QUAN, 0)) as total_dose, -- 총 투약량 (1회복용량 x 복용횟수 x 일수)
SUM(ISNULL(P.DRUPRICE, 0)) as total_amount, -- DRUPRICE 합계
COUNT(DISTINCT P.PreSerial) as prescription_count,
MAX(COALESCE(NULLIF(G.BARCODE, ''), '')) as barcode,
MAX(ISNULL(IT.IM_QT_sale_debit, 0)) as current_stock,
MAX(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
ORDER BY SUM(ISNULL(P.INV_QUAN, 0)) 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/<cuscode>')
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/<cuscode>/phone', methods=['PUT'])
def api_update_phone(cuscode):
"""환자 전화번호 수정 API"""
try:
data = request.get_json() or {}
new_phone = data.get('phone', '').strip()
# 길이 제한 (20자)
if len(new_phone) > 20:
return jsonify({'success': False, 'error': '전화번호는 20자를 초과할 수 없습니다.'}), 400
base_session = db_manager.get_session('PM_BASE')
# PHONE 업데이트
update_query = text("""
UPDATE CD_PERSON
SET PHONE = :phone
WHERE CUSCODE = :cuscode
""")
result = base_session.execute(update_query, {'phone': new_phone, 'cuscode': cuscode})
base_session.commit()
if result.rowcount == 0:
return jsonify({'success': False, 'error': '해당 고객을 찾을 수 없습니다.'}), 404
return jsonify({
'success': True,
'message': '전화번호가 저장되었습니다.',
'phone': new_phone
})
except Exception as e:
logging.error(f"전화번호 수정 오류: {e}")
return jsonify({'success': False, 'error': str(e)}), 500
@app.route('/api/members/<cuscode>/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/<phone>')
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/<int:log_id>')
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/<int:item_id>', 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/<int:pet_id>', 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/<int:pet_id>', 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/<int:pet_id>/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/<species>')
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,
M.SL_CD_custom AS customer_code,
ISNULL(S.card_total, 0) AS card_total,
ISNULL(S.cash_total, 0) AS cash_total,
ISNULL(M.SL_MY_total, 0) AS total_amount,
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, customer_code, 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 if customer else '',
'customer_code': customer_code if customer_code else '0000000000',
'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/<order_no>')
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/customers/<cus_code>/mileage')
def api_customer_mileage(cus_code):
"""
고객 마일리지 조회 API (비동기)
- CD_PERSON에서 이름+전화번호 조회
- SQLite users와 이름+전화뒤4자리로 매칭
"""
if not cus_code or cus_code == '0000000000':
return jsonify({'success': False, 'mileage': None})
mssql_conn = None
try:
# 1. CD_PERSON에서 이름, 전화번호 조회
mssql_engine = db_manager.get_engine('PM_BASE')
mssql_conn = mssql_engine.raw_connection()
cursor = mssql_conn.cursor()
cursor.execute("""
SELECT PANAME, PHONE, TEL_NO, PHONE2
FROM CD_PERSON
WHERE CUSCODE = ?
""", cus_code)
row = cursor.fetchone()
if not row:
return jsonify({'success': False, 'mileage': None})
name, phone1, phone2, phone3 = row
phone = phone1 or phone2 or phone3 or ''
phone_digits = ''.join(c for c in phone if c.isdigit())
last4 = phone_digits[-4:] if len(phone_digits) >= 4 else ''
if not name or not last4:
return jsonify({'success': False, 'mileage': None})
# 2. SQLite에서 이름+전화뒤4자리로 매칭
sqlite_conn = db_manager.get_sqlite_connection()
sqlite_cursor = sqlite_conn.cursor()
sqlite_cursor.execute("""
SELECT nickname, phone, mileage_balance
FROM users
""")
for user in sqlite_cursor.fetchall():
user_phone = ''.join(c for c in (user['phone'] or '') if c.isdigit())
user_last4 = user_phone[-4:] if len(user_phone) >= 4 else ''
if user['nickname'] == name and user_last4 == last4:
return jsonify({
'success': True,
'mileage': user['mileage_balance'] or 0,
'name': name
})
return jsonify({'success': False, 'mileage': None})
except Exception as e:
logging.error(f"마일리지 조회 오류: {e}")
return jsonify({'success': False, 'error': str(e)}), 500
finally:
if mssql_conn:
mssql_conn.close()
@app.route('/api/customers/search')
def api_customers_search():
"""
고객 검색 API (CD_PERSON + 최근 조제/구매 활동)
- name: 검색할 이름
- 결과: 최근 활동순 정렬, 생년월일 포함
"""
name = request.args.get('name', '').strip()
if not name or len(name) < 2:
return jsonify({'success': False, 'error': '이름을 2자 이상 입력하세요.'}), 400
mssql_conn = None
try:
mssql_engine = db_manager.get_engine('PM_BASE')
mssql_conn = mssql_engine.raw_connection()
cursor = mssql_conn.cursor()
# CD_PERSON에서 이름으로 검색 + 최근 조제/구매일 조인
cursor.execute("""
SELECT DISTINCT
p.CUSCODE,
p.PANAME,
LEFT(p.PANUM, 6) AS birth_date,
p.PHONE,
(SELECT MAX(Indate) FROM PM_PRES.dbo.PS_main WHERE CusCode = p.CUSCODE) AS last_rx,
(SELECT MAX(InsertTime) FROM PM_PRES.dbo.SALE_MAIN WHERE SL_CD_custom = p.CUSCODE) AS last_sale
FROM CD_PERSON p
WHERE p.PANAME LIKE ?
ORDER BY p.PANAME
""", f'%{name}%')
rows = cursor.fetchall()
results = []
for row in rows:
cus_code, pa_name, birth, phone, last_rx, last_sale = row
# 최근 활동일 계산
last_activity = None
activity_type = None
if last_rx and last_sale:
# 둘 다 있으면 더 최근 것
rx_date = datetime.strptime(last_rx, '%Y%m%d') if isinstance(last_rx, str) else last_rx
if isinstance(last_sale, datetime):
if rx_date > last_sale:
last_activity = rx_date
activity_type = '조제'
else:
last_activity = last_sale
activity_type = '구매'
else:
last_activity = rx_date
activity_type = '조제'
elif last_rx:
last_activity = datetime.strptime(last_rx, '%Y%m%d') if isinstance(last_rx, str) else last_rx
activity_type = '조제'
elif last_sale:
last_activity = last_sale
activity_type = '구매'
# 며칠 전 계산
days_ago = None
if last_activity:
if isinstance(last_activity, datetime):
days_ago = (datetime.now() - last_activity).days
else:
days_ago = (datetime.now() - datetime.strptime(str(last_activity)[:8], '%Y%m%d')).days
results.append({
'cus_code': cus_code,
'name': pa_name,
'birth': birth if birth else '',
'phone': phone if phone else '',
'activity_type': activity_type,
'days_ago': days_ago,
'last_activity': last_activity.strftime('%Y-%m-%d') if last_activity else None
})
# 최근 활동순 정렬 (활동 있는 것 먼저, 그 중 최근 것 먼저)
results.sort(key=lambda x: (x['days_ago'] is None, x['days_ago'] if x['days_ago'] is not None else 9999))
return jsonify({
'success': True,
'results': results[:50] # 최대 50개
})
except Exception as e:
logging.error(f"고객 검색 오류: {e}")
return jsonify({'success': False, 'error': str(e)}), 500
finally:
if mssql_conn:
mssql_conn.close()
@app.route('/api/pos-live/<order_no>/customer', methods=['PUT'])
def api_pos_live_update_customer(order_no):
"""
판매 건의 고객 정보 업데이트 API
- cus_code: 고객 코드 (CD_PERSON.CUSCODE)
- cus_name: 고객 이름
"""
data = request.get_json()
if not data:
return jsonify({'success': False, 'error': 'JSON 데이터 필요'}), 400
cus_code = data.get('cus_code', '').strip()
cus_name = data.get('cus_name', '').strip()
if not cus_code or not cus_name:
return jsonify({'success': False, 'error': '고객 코드와 이름 필요'}), 400
mssql_conn = None
try:
mssql_engine = db_manager.get_engine('PM_PRES')
mssql_conn = mssql_engine.raw_connection()
cursor = mssql_conn.cursor()
# SALE_MAIN 업데이트
cursor.execute("""
UPDATE SALE_MAIN
SET SL_CD_custom = ?, SL_NM_custom = ?
WHERE SL_NO_order = ?
""", cus_code, cus_name, order_no)
mssql_conn.commit()
return jsonify({
'success': True,
'message': f'고객 정보가 {cus_name}({cus_code})으로 업데이트되었습니다.'
})
except Exception as e:
logging.error(f"고객 정보 업데이트 오류: {e}")
if mssql_conn:
mssql_conn.rollback()
return jsonify({'success': False, 'error': str(e)}), 500
finally:
if mssql_conn:
mssql_conn.close()
@app.route('/api/admin/user-mileage/<phone>')
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/<barcode>', 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/<barcode>', 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/<barcode>')
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/<barcode>', 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/<barcode>/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/<barcode>/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 태그 제거 (줄바꿈 보존)
# <p>, <br>, </div> 등을 줄바꿈으로 변환
text = re.sub(r'</p>|<br\s*/?>|</div>', '\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 ''
# <p>, </div> → 줄바꿈
text = re.sub(r'</p>|</div>', '\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 = '<table style="width:100%;border-collapse:collapse;margin:10px 0;font-size:13px;">'
html += '<tr style="background:#dbeafe;">'
for h in headers:
html += f'<th style="border:1px solid #93c5fd;padding:8px;text-align:center;">{h}</th>'
html += '</tr>'
html += '<tr style="background:#fff;">'
for v in values:
html += f'<td style="border:1px solid #93c5fd;padding:8px;text-align:center;font-weight:bold;color:#1e40af;">{v}</td>'
html += '</tr>'
html += '</table>'
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 = '<table style="width:100%;border-collapse:collapse;margin:10px 0;font-size:13px;">'
html += '<tr style="background:#dbeafe;">'
for h in header_cols:
html += f'<th style="border:1px solid #93c5fd;padding:8px;text-align:center;">{h}</th>'
html += '</tr>'
for i, row in enumerate(table_rows):
bg = '#fff' if i % 2 == 0 else '#f8fafc'
html += f'<tr style="background:{bg};">'
for cell in row:
html += f'<td style="border:1px solid #93c5fd;padding:6px 8px;text-align:center;font-size:12px;">{cell}</td>'
# 셀 수가 헤더보다 적으면 빈 셀 채우기
for _ in range(len(header_cols) - len(row)):
html += '<td style="border:1px solid #93c5fd;padding:6px 8px;"></td>'
html += '</tr>'
html += '</table>'
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 ''
# 원본에 <table> 태그가 있으면 → HTML 테이블 보존 + 스타일 적용
if '<table' in raw_html:
# table 앞뒤 텍스트 분리
before_html = re.split(r'<div[^>]*class="_table_wrap', raw_html, maxsplit=1)[0] if '_table_wrap' in raw_html else raw_html.split('<table')[0]
after_match = re.search(r'</table>(.*)', 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'<table[^>]*>(.*?)</table>', raw_html, re.DOTALL)
if table_match:
table_inner = table_match.group(1)
# caption, hidden 요소 제거
table_inner = re.sub(r'<caption>.*?</caption>', '', table_inner, flags=re.DOTALL)
# 기존 style 제거하고 새 스타일 적용
table_inner = re.sub(r'<td[^>]*>', '<td style="border:1px solid #93c5fd;padding:6px 8px;text-align:center;font-size:12px;">', table_inner)
table_inner = re.sub(r'<th[^>]*>', '<th style="border:1px solid #93c5fd;padding:8px;text-align:center;background:#dbeafe;">', table_inner)
# 첫 번째 tr에 헤더 배경색 적용
table_inner = re.sub(r'<tr[^>]*>', '<tr>', table_inner)
table_inner = table_inner.replace('<tr>', '<tr style="background:#dbeafe;">', 1)
# p 태그 제거 (셀 내부)
table_inner = re.sub(r'<p[^>]*>', '', table_inner)
table_inner = re.sub(r'</p>', '<br>', table_inner)
# tbody 유지
styled_table = f'<table style="width:100%;border-collapse:collapse;margin:10px 0;font-size:13px;">{table_inner}</table>'
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)
# 원본에 <table> 없으면 기존 로직
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 ('', '', '======', '<table')),
'precautions': format_items(strip_html(row.precautions)),
# 성분 가이드 (component_guide JOIN)
'component_code': row.component_code,
'component_name': row.component_name_ko,
'dosing_interval': dosing_interval,
'companion_drugs': row.companion_drugs,
'guide_contraindication': row.guide_contraindication
}
})
except Exception as e:
return jsonify({'success': False, 'error': str(e)}), 500
# ═══════════════════════════════════════════════════════════════════════════════
# PAAI (Pharmacist Assistant AI) Admin 라우트
# ═══════════════════════════════════════════════════════════════════════════════
@app.route('/admin/paai')
def admin_paai():
"""PAAI 분석 로그 관리 페이지"""
return render_template('admin_paai.html')
@app.route('/api/paai/logs')
def api_paai_logs():
"""PAAI 로그 목록 조회"""
from db.paai_logger import get_recent_logs
limit = int(request.args.get('limit', 100))
status = request.args.get('status', '')
has_severe = request.args.get('has_severe', '')
date = request.args.get('date', '')
try:
logs = get_recent_logs(
limit=limit,
status=status if status else None,
has_severe=True if has_severe == 'true' else (False if has_severe == 'false' else None),
date=date if date else None
)
return jsonify({'success': True, 'logs': logs, 'count': len(logs)})
except Exception as e:
return jsonify({'success': False, 'error': str(e)}), 500
@app.route('/api/paai/logs/stats')
def api_paai_logs_stats():
"""PAAI 로그 통계"""
from db.paai_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)}), 500
@app.route('/api/paai/logs/<int:log_id>')
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/<session_id>')
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/<session_id>/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/<session_id>')
def mobile_upload_page(session_id):
"""모바일 업로드 페이지"""
session = upload_sessions.get(session_id)
barcode = request.args.get('barcode', '')
if not session:
return render_template_string('''
<!DOCTYPE html>
<html><head><meta charset="UTF-8"><meta name="viewport" content="width=device-width,initial-scale=1">
<title>세션 만료</title>
<style>body{font-family:sans-serif;display:flex;align-items:center;justify-content:center;height:100vh;margin:0;background:#f8d7da;color:#721c24;text-align:center;padding:20px;}
.msg{font-size:18px;}</style></head>
<body><div class="msg">⏰ 세션이 만료되었습니다.<br><br>PC에서 다시 QR코드를 생성해주세요.</div></body></html>
''')
return render_template_string('''
<!DOCTYPE html>
<html lang="ko">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width,initial-scale=1,maximum-scale=1">
<title>제품 이미지 촬영</title>
<style>
* { margin: 0; padding: 0; box-sizing: border-box; }
body {
font-family: -apple-system, BlinkMacSystemFont, sans-serif;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
min-height: 100vh;
padding: 20px;
}
.container {
max-width: 400px;
margin: 0 auto;
background: #fff;
border-radius: 20px;
padding: 24px;
box-shadow: 0 10px 40px rgba(0,0,0,0.2);
}
h1 {
font-size: 20px;
color: #333;
margin-bottom: 8px;
text-align: center;
}
.barcode {
background: #f0f0f0;
padding: 8px 16px;
border-radius: 8px;
font-family: monospace;
font-size: 14px;
text-align: center;
margin-bottom: 20px;
color: #666;
}
.preview-area {
width: 100%;
aspect-ratio: 1;
background: #f5f5f5;
border-radius: 12px;
display: flex;
align-items: center;
justify-content: center;
margin-bottom: 20px;
overflow: hidden;
border: 2px dashed #ddd;
}
.preview-area.has-image { border: none; }
.preview-area img {
width: 100%;
height: 100%;
object-fit: cover;
}
.placeholder {
color: #999;
font-size: 14px;
text-align: center;
}
.placeholder .icon { font-size: 48px; margin-bottom: 8px; }
.btn {
width: 100%;
padding: 16px;
border: none;
border-radius: 12px;
font-size: 16px;
font-weight: 600;
cursor: pointer;
margin-bottom: 12px;
transition: transform 0.1s;
}
.btn:active { transform: scale(0.98); }
.btn-camera {
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
color: #fff;
}
.btn-upload {
background: #10b981;
color: #fff;
}
.btn-upload:disabled {
background: #ccc;
cursor: not-allowed;
}
input[type="file"] { display: none; }
.status {
text-align: center;
padding: 16px;
border-radius: 12px;
font-weight: 600;
}
.status.success { background: #d1fae5; color: #065f46; }
.status.error { background: #fee2e2; color: #991b1b; }
.loading { display: none; text-align: center; padding: 20px; }
.loading.show { display: block; }
.spinner {
width: 40px; height: 40px;
border: 4px solid #eee;
border-top-color: #667eea;
border-radius: 50%;
animation: spin 1s linear infinite;
margin: 0 auto 12px;
}
@keyframes spin { to { transform: rotate(360deg); } }
</style>
</head>
<body>
<div class="container">
<h1>📸 제품 이미지 촬영</h1>
<div class="barcode">바코드: {{ barcode }}</div>
<div class="preview-area" id="previewArea">
<div class="placeholder">
<div class="icon">📷</div>
<div>카메라 버튼을 눌러<br>제품 사진을 촬영하세요</div>
</div>
</div>
<input type="file" id="fileInput" accept="image/*" capture="environment">
<button class="btn btn-camera" onclick="document.getElementById('fileInput').click()">
📷 카메라로 촬영
</button>
<button class="btn btn-upload" id="uploadBtn" disabled onclick="uploadImage()">
⬆️ 업로드
</button>
<div class="loading" id="loading">
<div class="spinner"></div>
<div>업로드 중...</div>
</div>
<div class="status" id="status" style="display:none;"></div>
</div>
<script>
const sessionId = '{{ session_id }}';
let imageData = null;
document.getElementById('fileInput').addEventListener('change', function(e) {
const file = e.target.files[0];
if (!file) return;
const reader = new FileReader();
reader.onload = function(ev) {
imageData = ev.target.result;
const preview = document.getElementById('previewArea');
preview.innerHTML = '<img src="' + imageData + '">';
preview.classList.add('has-image');
document.getElementById('uploadBtn').disabled = false;
};
reader.readAsDataURL(file);
});
async function uploadImage() {
if (!imageData) return;
document.getElementById('loading').classList.add('show');
document.getElementById('uploadBtn').disabled = true;
try {
// base64에서 데이터 부분만 추출
const base64Data = imageData.split(',')[1];
const res = await fetch('/api/upload-session/' + sessionId + '/image', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ image_base64: base64Data })
});
const data = await res.json();
document.getElementById('loading').classList.remove('show');
const status = document.getElementById('status');
if (data.success) {
status.className = 'status success';
status.innerHTML = '✅ 업로드 완료!<br><br>PC에서 확인하세요.';
status.style.display = 'block';
// 버튼 숨기기
document.querySelector('.btn-camera').style.display = 'none';
document.getElementById('uploadBtn').style.display = 'none';
} else {
status.className = 'status error';
status.textContent = '' + (data.error || '업로드 실패');
status.style.display = 'block';
document.getElementById('uploadBtn').disabled = false;
}
} catch (err) {
document.getElementById('loading').classList.remove('show');
const status = document.getElementById('status');
status.className = 'status error';
status.textContent = '❌ 네트워크 오류';
status.style.display = 'block';
document.getElementById('uploadBtn').disabled = false;
}
}
</script>
</body>
</html>
''', 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/<drug_code>/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/<drug_code>/prescriptions')
def api_drug_usage_prescriptions(drug_code):
"""약품별 조제(매출) 상세 API"""
from utils.drug_unit import get_drug_unit
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')
drug_session = db_manager.get_session('PM_DRUG')
# 약품 정보 조회 (단위 판별용)
drug_info = drug_session.execute(text("""
SELECT GoodsName, SUNG_CODE FROM CD_GOODS WHERE DrugCode = :drug_code
"""), {'drug_code': drug_code}).fetchone()
goods_name = drug_info.GoodsName if drug_info else ''
sung_code = drug_info.SUNG_CODE if drug_info else ''
unit = get_drug_unit(goods_name, sung_code)
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명)
total_usage = 0 # 총 사용량
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 ''
qty = dosage * freq * days
total_usage += qty
# 중복 제외 환자 목록 (최근순, 최대 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': qty
})
return jsonify({
'success': True,
'drug_code': drug_code,
'total_count': len(items),
'unique_patients': len(seen_patients),
'recent_patients': recent_patients,
'total_usage': total_usage,
'unit': unit,
'items': items
})
except Exception as e:
logging.error(f"drug-usage prescriptions API 오류: {e}")
return jsonify({'success': False, 'error': str(e)}), 500
# ═══════════════════════════════════════════════════════════════════════════
# 재고 시계열 분석 API
# ═══════════════════════════════════════════════════════════════════════════
@app.route('/admin/stock-analytics')
def admin_stock_analytics():
"""재고 시계열 분석 페이지"""
return render_template('admin_stock_analytics.html')
@app.route('/api/stock-analytics/daily-trend')
def api_stock_daily_trend():
"""
일별 입출고 추이 API
GET /api/stock-analytics/daily-trend?start_date=2026-01-01&end_date=2026-03-13&drug_code=A123456789
"""
conn = None
try:
start_date = request.args.get('start_date', '')
end_date = request.args.get('end_date', '')
drug_code = request.args.get('drug_code', '').strip()
# 날짜 형식 변환 (YYYY-MM-DD -> YYYYMMDD)
start_date_fmt = start_date.replace('-', '') if start_date else (datetime.now() - timedelta(days=30)).strftime('%Y%m%d')
end_date_fmt = end_date.replace('-', '') if end_date else datetime.now().strftime('%Y%m%d')
# 새로운 pyodbc 연결 (동시 요청 충돌 방지)
import pyodbc
conn_str = (
'DRIVER={ODBC Driver 17 for SQL Server};'
'SERVER=192.168.0.4\\PM2014;'
'DATABASE=PM_DRUG;'
'UID=sa;'
'PWD=tmddls214!%(;'
'TrustServerCertificate=yes'
)
conn = pyodbc.connect(conn_str, timeout=10)
cursor = conn.cursor()
# 특정 약품 또는 전체 일별 입출고 집계
# credit=출고, debit=입고 (약국 재고 기준)
if drug_code:
# 특정 약품
cursor.execute("""
SELECT
IM_DT_appl as date,
SUM(ISNULL(IM_QT_sale_debit, 0)) as inbound,
SUM(ISNULL(IM_QT_sale_credit, 0)) as outbound
FROM IM_date_total
WHERE IM_DT_appl >= ?
AND IM_DT_appl <= ?
AND DrugCode = ?
GROUP BY IM_DT_appl
ORDER BY IM_DT_appl
""", (start_date_fmt, end_date_fmt, drug_code))
else:
# 전체 합계
cursor.execute("""
SELECT
IM_DT_appl as date,
SUM(ISNULL(IM_QT_sale_debit, 0)) as inbound,
SUM(ISNULL(IM_QT_sale_credit, 0)) as outbound
FROM IM_date_total
WHERE IM_DT_appl >= ?
AND IM_DT_appl <= ?
GROUP BY IM_DT_appl
ORDER BY IM_DT_appl
""", (start_date_fmt, end_date_fmt))
rows = cursor.fetchall()
items = []
total_inbound = 0
total_outbound = 0
for row in rows:
date_str = str(row.date) if row.date else ''
# YYYYMMDD -> YYYY-MM-DD
formatted_date = f"{date_str[:4]}-{date_str[4:6]}-{date_str[6:8]}" if len(date_str) == 8 else date_str
inbound = int(row.inbound or 0)
outbound = int(row.outbound or 0)
items.append({
'date': formatted_date,
'inbound': inbound,
'outbound': outbound
})
total_inbound += inbound
total_outbound += outbound
conn.close()
return jsonify({
'success': True,
'drug_code': drug_code if drug_code else 'ALL',
'items': items,
'stats': {
'total_inbound': total_inbound,
'total_outbound': total_outbound,
'net_change': total_inbound - total_outbound,
'data_count': len(items)
}
})
except Exception as e:
if conn:
conn.close()
logging.error(f"daily-trend API 오류: {e}")
return jsonify({'success': False, 'error': str(e)}), 500
@app.route('/api/stock-analytics/top-usage')
def api_stock_top_usage():
"""
기간별 사용량 TOP N API
GET /api/stock-analytics/top-usage?start_date=2026-01-01&end_date=2026-03-13&limit=10
"""
try:
start_date = request.args.get('start_date', '')
end_date = request.args.get('end_date', '')
limit = int(request.args.get('limit', '10'))
# 날짜 형식 변환
start_date_fmt = start_date.replace('-', '') if start_date else (datetime.now() - timedelta(days=30)).strftime('%Y%m%d')
end_date_fmt = end_date.replace('-', '') if end_date else datetime.now().strftime('%Y%m%d')
drug_session = db_manager.get_session('PM_DRUG')
# 출고량 기준 TOP N (credit=출고, debit=입고)
query = text("""
SELECT TOP(:limit)
D.DrugCode as drug_code,
G.GoodsName as product_name,
G.SplName as supplier,
SUM(ISNULL(D.IM_QT_sale_credit, 0)) as total_outbound,
SUM(ISNULL(D.IM_QT_sale_debit, 0)) as total_inbound,
ISNULL(IT.IM_QT_sale_debit, 0) as current_stock
FROM IM_date_total D
LEFT JOIN CD_GOODS G ON D.DrugCode = G.DrugCode
LEFT JOIN IM_total IT ON D.DrugCode = IT.DrugCode
WHERE D.IM_DT_appl >= :start_date
AND D.IM_DT_appl <= :end_date
GROUP BY D.DrugCode, G.GoodsName, G.SplName, IT.IM_QT_sale_debit
ORDER BY SUM(ISNULL(D.IM_QT_sale_credit, 0)) DESC
""")
rows = drug_session.execute(query, {
'start_date': start_date_fmt,
'end_date': end_date_fmt,
'limit': limit
}).fetchall()
items = []
for row in rows:
items.append({
'drug_code': row.drug_code or '',
'product_name': row.product_name or '알 수 없음',
'supplier': row.supplier or '',
'total_outbound': int(row.total_outbound or 0),
'total_inbound': int(row.total_inbound or 0),
'current_stock': int(row.current_stock or 0)
})
return jsonify({
'success': True,
'items': items,
'limit': limit
})
except Exception as e:
logging.error(f"top-usage API 오류: {e}")
return jsonify({'success': False, 'error': str(e)}), 500
@app.route('/api/stock-analytics/stock-level')
def api_stock_level():
"""
특정 약품 재고 변화 (누적) API
GET /api/stock-analytics/stock-level?drug_code=A123456789&start_date=2026-01-01&end_date=2026-03-13
"""
conn = None
try:
drug_code = request.args.get('drug_code', '').strip()
start_date = request.args.get('start_date', '')
end_date = request.args.get('end_date', '')
if not drug_code:
return jsonify({'success': False, 'error': 'drug_code 파라미터 필요'}), 400
# 날짜 형식 변환
start_date_fmt = start_date.replace('-', '') if start_date else (datetime.now() - timedelta(days=30)).strftime('%Y%m%d')
end_date_fmt = end_date.replace('-', '') if end_date else datetime.now().strftime('%Y%m%d')
# 새로운 pyodbc 연결 (동시 요청 충돌 방지)
import pyodbc
conn_str = (
'DRIVER={ODBC Driver 17 for SQL Server};'
'SERVER=192.168.0.4\\PM2014;'
'DATABASE=PM_DRUG;'
'UID=sa;'
'PWD=tmddls214!%(;'
'TrustServerCertificate=yes'
)
conn = pyodbc.connect(conn_str, timeout=10)
cursor = conn.cursor()
# 현재 재고
cursor.execute("""
SELECT
IT.IM_QT_sale_debit as current_stock,
G.GoodsName as product_name,
G.SplName as supplier
FROM IM_total IT
LEFT JOIN CD_GOODS G ON IT.DrugCode = G.DrugCode
WHERE IT.DrugCode = ?
""", (drug_code,))
stock_row = cursor.fetchone()
current_stock = int(stock_row.current_stock or 0) if stock_row else 0
product_name = stock_row.product_name if stock_row else drug_code
supplier = stock_row.supplier if stock_row else ''
# 일별 입출고 데이터 (역순으로 누적 계산)
# credit=출고, debit=입고 (약국 재고 기준)
cursor.execute("""
SELECT
IM_DT_appl as date,
SUM(ISNULL(IM_QT_sale_debit, 0)) as inbound,
SUM(ISNULL(IM_QT_sale_credit, 0)) as outbound
FROM IM_date_total
WHERE DrugCode = ?
AND IM_DT_appl >= ?
AND IM_DT_appl <= ?
GROUP BY IM_DT_appl
ORDER BY IM_DT_appl DESC
""", (drug_code, start_date_fmt, end_date_fmt))
rows = cursor.fetchall()
# 역순으로 누적 재고 계산 (현재 → 과거)
items = []
running_stock = current_stock
for row in rows:
date_str = str(row.date) if row.date else ''
formatted_date = f"{date_str[:4]}-{date_str[4:6]}-{date_str[6:8]}" if len(date_str) == 8 else date_str
inbound = int(row.inbound or 0)
outbound = int(row.outbound or 0)
items.append({
'date': formatted_date,
'stock': running_stock,
'inbound': inbound,
'outbound': outbound
})
# 과거로 갈수록: 재고 = 재고 - 입고 + 출고
running_stock = running_stock - inbound + outbound
# 시간순 정렬 (과거 → 현재)
items.reverse()
conn.close()
return jsonify({
'success': True,
'drug_code': drug_code,
'product_name': product_name,
'supplier': supplier,
'current_stock': current_stock,
'items': items
})
except Exception as e:
if conn:
conn.close()
logging.error(f"stock-level API 오류: {e}")
return jsonify({'success': False, 'error': str(e)}), 500
@app.route('/api/stock-analytics/search-drugs')
def api_search_drugs():
"""
약품 검색 API (재고 분석용)
GET /api/stock-analytics/search-drugs?q=타이레놀&limit=20
"""
try:
query = request.args.get('q', '').strip()
limit = int(request.args.get('limit', '20'))
if not query or len(query) < 2:
return jsonify({'success': True, 'items': []})
drug_session = db_manager.get_session('PM_DRUG')
search_query = text("""
SELECT TOP(:limit)
G.DrugCode as drug_code,
G.GoodsName as product_name,
G.SplName as supplier,
ISNULL(IT.IM_QT_sale_debit, 0) as current_stock
FROM CD_GOODS G
LEFT JOIN IM_total IT ON G.DrugCode = IT.DrugCode
WHERE G.GoodsName LIKE :keyword
OR G.DrugCode LIKE :keyword
ORDER BY
CASE WHEN G.GoodsName LIKE :exact_keyword THEN 0 ELSE 1 END,
IT.IM_QT_sale_debit DESC
""")
rows = drug_session.execute(search_query, {
'keyword': f'%{query}%',
'exact_keyword': f'{query}%',
'limit': limit
}).fetchall()
items = []
for row in rows:
items.append({
'drug_code': row.drug_code or '',
'product_name': row.product_name or '',
'supplier': row.supplier or '',
'current_stock': int(row.current_stock or 0)
})
return jsonify({
'success': True,
'items': items
})
except Exception as e:
logging.error(f"search-drugs 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)